20 Commits

Author SHA1 Message Date
Millian Lamiaux
9943dce82d fix: add HealthKit entitlement and regenerate Xcode project to resolve NSInvalidArgumentException
Some checks failed
CI / TypeScript (pull_request) Failing after 5s
CI / ESLint (pull_request) Failing after 4s
CI / Tests (pull_request) Failing after 5s
CI / Build Check (pull_request) Has been skipped
CI / Admin Web Tests (pull_request) Successful in 2m8s
CI / Deploy Edge Functions (pull_request) Has been skipped
The iOS app target was missing the com.apple.developer.healthkit entitlement
and the Xcode project was out of sync with project.yml, causing a crash when
HealthKitService requested write authorization for active energy, workouts, and
heart rate types.
2026-04-23 22:34:54 +02:00
Millian Lamiaux
cf096f2068 Redesign Activity tab with animated rings, monthly calendar, and global stats
Some checks failed
CI / TypeScript (push) Failing after 7s
CI / ESLint (push) Failing after 3s
CI / Tests (push) Failing after 7s
CI / Build Check (push) Has been skipped
CI / Admin Web Tests (push) Successful in 2m6s
CI / Deploy Edge Functions (push) Has been skipped
2026-04-22 01:18:42 +02:00
Millian Lamiaux
d74c47b1a8 Move level badge to top-left and free badge to top-right in FeaturedProgramCard 2026-04-22 00:54:42 +02:00
Millian Lamiaux
0f5b7b9e18 feat(i18n): complete internationalization for iOS + watchOS across all views
Migrate every hardcoded Text("...") string to the L10n / LocalizedStringResource
type-safe key system with full en/fr/de/es translations (4 languages).

iOS changes (TabataGo target):
- Strings.swift: ~90 new L10n keys across 13 groups (action, tab, home, zone,
  level, programs, programDetail, player, profile, settings, policy, paywall,
  health, complete, activity, onboarding, goal)
- Localizable.xcstrings: 145 → 245+ keys with fr/de/es translations
- Model enums: FitnessLevel.label & FitnessGoal.label changed from String to
  LocalizedStringResource, backed by L10n.level/goal keys
- Component param types changed to LocalizedStringResource: StatBadge,
  SectionHeader, ProfileRow, PolicySection, CompletionStat, FeatureRow,
  OnboardingHeader, PrimaryButton, SelectionCard
- All 18 view files updated: HomeTab, ActivityTab, ProgramsTab, ProfileTab,
  MainTabView, SettingsView, PolicyViews, CompletionView, BodyZoneView,
  ProgramDetailView, PaywallView, OnboardingView, PlayerView

Watch changes (TabataGoWatch target):
- New Localizable.xcstrings: 23 keys with en/fr/de/es (phase labels, idle
  state, activity rings, complication strings)
- New WatchL10n.swift: type-safe enum (needs manual Xcode target membership)
- Updated: WatchPlayerView, WatchIdleView, WatchActivityView,
  TabataGoComplication (inline LocalizedStringResource for widget target)

Both iOS and watchOS targets build with zero errors.
2026-04-22 00:41:19 +02:00
Millian Lamiaux
e28bebea79 Redesign FeaturedProgramCard and reorder Home sections
- Replace external pill badges (level + FREE) with immersive overlay card:
  level tag pinned top-right with ultraThinMaterial blur, FREE shown inline
  with checkmark.seal.fill icon, dark scrim for text legibility
- Remove card drop shadow
- Move Browse by Zone section above Featured in Home tab scroll order
2026-04-21 23:18:15 +02:00
Millian Lamiaux
9f15ae2d79 fix: dismiss paywall and sync premium state after successful purchase
- Add purchaseSucceeded flag to PurchaseViewModel, set on purchase success
- PaywallView observes the flag and dismisses itself automatically
- ProfileTab.syncSubscription() writes PurchaseService.currentPlan back
  to UserProfile.subscriptionRaw via SwiftData on sheet dismiss
2026-04-21 23:00:45 +02:00
Millian Lamiaux
877f836f19 Replace double chip filters with segmented control + dropdown menu in ProgramsTab
Swap the two horizontal FilterChip scroll rows with a native segmented
Picker for body zone and a toolbar Menu for difficulty level. Fix zone
values to match Supabase enum (upper-body, lower-body, full-body).
Remove unused FilterChip struct.
2026-04-21 22:47:47 +02:00
Millian Lamiaux
2413bc0356 Remove 'All Workouts' section from HomeTab 2026-04-21 22:04:56 +02:00
Millian Lamiaux
89cca25e22 remove Expo project and all related files
Remove the entire Expo/React Native application: routes (app/), source
code (src/), assets, iOS native build, config plugins, StoreKit config,
npm dependencies, TypeScript/ESLint/Vitest configs, and Expo-specific
documentation. The repository now contains only: admin-web, supabase,
youtube-worker, tabatago-swift, docs, scripts, and CI/tooling configs.
2026-04-21 21:55:00 +02:00
Millian Lamiaux
8c90b73d90 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/.
2026-04-21 21:51:11 +02:00
Millian Lamiaux
d4edf54aeb update data layer, i18n locales & service exports
Rewrite data index, useTranslatedData, and workoutPrograms for the new
Supabase-backed model. Update hooks/stores barrel exports. Extend user
types and workoutProgram types. Refresh all i18n locale files (en, fr,
de, es) with updated screen translations. Add music service helpers.
Fix minor test import updates.
2026-04-21 21:51:01 +02:00
Millian Lamiaux
5888aac08e refactor screens, navigation & player for new architecture
Simplify Home, Activity, Profile, Complete, Player, and Program screens
to work with the new Supabase-driven data layer. Update root and tab
layouts. Add Settings, Terms, and Zone routes. Add OfflineBanner
component and progressStore. Update all player sub-components to use
the refreshed design system tokens.
2026-04-21 21:50:48 +02:00
Millian Lamiaux
04b83fc419 refresh design system: colors, typography, native components
Update color palette, dark theme tokens, typography scale, and border
radius constants. Refactor native UI primitives (NativeButton, NativeList,
NativeSection, NativeLabeledRow) for the new design language. Simplify
OnboardingStep. Remove legacy WorkoutCard and CollectionCard components.
2026-04-21 21:50:31 +02:00
Millian Lamiaux
13262305e5 remove obsolete tests for deleted data, stores & components
Delete tests for removed modules: WorkoutCard, CollectionCard, Skeleton,
achievements, dataService, programs, trainers, useTranslatedData,
workouts, useSupabaseData, activityStore, programStore,
tabataProgramStore, and workoutProgramStore.
2026-04-21 21:50:19 +02:00
Millian Lamiaux
3fe9d926ad remove legacy data layer, stores & Supabase seed
Delete hardcoded programs data (including tabata sub-modules), workouts,
achievements, collections, trainers, and the dataService abstraction.
Remove activityStore, programStore, and tabataProgramStore which
depended on this data. Remove useSupabaseData hook and supabase seed
file. Data now comes from Supabase via the admin-web CMS.
2026-04-21 21:50:09 +02:00
Millian Lamiaux
d82205cd71 remove legacy admin panel, assessment, collection & workout routes
Remove the in-app admin panel (app/admin/, src/admin/), assessment
screen, collection detail routes, and workout detail/category/body-zone
routes. These features have been superseded by the admin-web dashboard
and the new program-based navigation. Also removes stale CLAUDE.md
context files and an accidentally committed image blob.
2026-04-21 21:49:58 +02:00
Millian Lamiaux
791f432334 refactor: code quality cleanup — remove any types, add logger, rename Kine to Tabata
- Phase 0: Rename all Kine references to Tabata (types, files, imports, i18n, analytics events)
- Phase 1: Add test coverage for tabataProgramStore, workoutProgramStore, and color utils (47 tests)
- Phase 2: Remove all `any` types from production code with proper typed replacements
- Phase 3: Replace ~60 raw console.* calls with __DEV__-gated logger utility
- Phase 4: Verify .DS_Store housekeeping (already clean)

0 TypeScript errors, 583/583 tests passing.
2026-04-17 18:56:24 +02:00
Millian Lamiaux
e0e02c4550 fix: align program detail screen with Dark Medical design system
Replace hardcoded #000 backgrounds with NAVY[900], use design system
tokens for badges (TYPOGRAPHY.LABEL, RADIUS.SM), progress bar
(RADIUS.PILL, 4px height), and CTA container (DARK.SCRIM).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 22:19:29 +02:00
Millian Lamiaux
0990ec8e11 refactor: remove explore tab, simplify to 3-tab layout (Home, Progress, Profile)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 22:06:11 +02:00
Millian Lamiaux
4458044d0e fix: resolve all 228 TypeScript errors across the project
- Exclude admin-web and supabase/functions from root tsconfig (185 errors)
- Add missing constant exports: FONT_FAMILY_SANS_BOLD/SEMIBOLD, BRAND_DANGER (9 errors)
- Fix React Query API destructuring in admin screens: data/isLoading aliases (12 errors)
- Add getWorkoutsByCategory to data layer, fix category screen types (5 errors)
- Add type assertions for Supabase never types in adminService and sync.ts (13 errors)
- Add missing vitest imports (vi, beforeEach) and replace removed PRIMARY_DARK (5 errors)
- Fix seed.ts to use program.weeks[].workouts[] instead of missing props (4 errors)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 17:53:02 +02:00
385 changed files with 23864 additions and 53702 deletions

View File

@@ -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"
}
}

View File

@@ -0,0 +1,576 @@
---
name: tabata-kine-design-system
description: >
Design system complet pour l'application Tabata Kiné. Utilise ce skill pour
toute tâche liée au design, aux composants UI, aux écrans, aux couleurs, à la
typographie ou aux décisions d'interface de l'app Tabata Kiné. Déclenche ce
skill dès que l'utilisateur mentionne : un écran de l'app (onboarding, séance,
dashboard, paywall, programmes), un composant (bouton, carte, timer, badge,
input), une couleur, une typographie, une animation, un espacement, ou demande
à coder un élément UI. Ce skill contient les règles non négociables du design
"Dark Medical" — le style qui différencie l'app de tous les concurrents fitness.
---
# Design System — Tabata Kiné
## Principe directeur : Dark Medical
L'app Tabata Kiné n'est **pas** une app fitness classique. C'est une app médicale
qui utilise le format tabata. Le style "Dark Medical" traduit visuellement ce
positionnement : fond sombre professionnel, vert santé comme seule couleur d'action,
expertise kiné visible à chaque écran.
**Règles absolues :**
- Pas de mode clair. Dark only, sans exception.
- Le vert (#00C896) ne sert qu'aux actions et à la validation.
- L'orange (#FF8A5C) ne sert qu'aux conseils kiné et alertes positives.
- Le rouge (#FF4444) est réservé au timer en phase d'urgence (<10s).
- Touch target minimum : 44×44px pour tous les éléments interactifs.
---
## 1. Tokens de couleur
### Fonds — Navy
| Token | Valeur | Usage |
|-------|--------|-------|
| `navy-900` | `#0D1B2A` | Fond principal de l'app |
| `navy-800` | `#112240` | Surface 1 — cartes par défaut |
| `navy-700` | `#1A3050` | Surface 2 — cartes surélevées |
| `navy-600` | `#243C5E` | Bordures actives |
### Vert Kiné — action & santé
| Token | Valeur | Usage |
|-------|--------|-------|
| `green-500` | `#00C896` | CTA principal, timer effort, progress |
| `green-600` | `#00A67C` | État hover / pressed |
| `green-700` | `#00875F` | État active deep |
| `green-dim` | `rgba(0,200,150,0.12)` | Fond badge, chip, card accent |
| `green-border` | `rgba(0,200,150,0.35)` | Bordure card accent |
### Texte & bordures
| Token | Valeur | Usage |
|-------|--------|-------|
| `white-100` | `#E6F1FF` | Texte primaire |
| `slate-300` | `#A8B2D8` | Texte secondaire |
| `slate-400` | `#8892B0` | Texte tertiaire, placeholders |
| `border-dim` | `rgba(168,178,216,0.15)` | Bordure par défaut |
| `border-hover` | `rgba(168,178,216,0.25)` | Bordure hover |
### Orange — conseils kiné uniquement
| Token | Valeur | Usage |
|-------|--------|-------|
| `orange-500` | `#FF8A5C` | Tip card border, badge Kiné+ |
| `orange-600` | `#E06A3C` | Hover orange |
| `orange-dim` | `rgba(255,138,92,0.12)` | Fond tip card |
### Sémantique
| Token | Valeur | Usage |
|-------|--------|-------|
| `red-500` | `#FF4444` | Timer urgence <10s UNIQUEMENT |
---
## 2. Typographie
### Familles
| Rôle | Famille | Notes |
|------|---------|-------|
| Titres émotionnels | Serif italique (ex: DM Serif Display, Georgia) | Célébration, fin de séance, accroches |
| Interface & corps | Sans-serif géométrique (ex: Outfit, DM Sans) | Navigation, descriptions, labels |
| Données & timer | Monospace (ex: DM Mono, JetBrains Mono) | Timer, stats, codes, metadata |
### Échelle
| Style | Famille | Taille | Poids | Usage |
|-------|---------|--------|-------|-------|
| `display` | Serif italic | 2832px | 400 | Fin de séance, titres forts |
| `heading-1` | Serif | 2224px | 500 | Titres de section |
| `heading-2` | Sans | 18px | 500 | Titre exercice, carte programme |
| `body` | Sans | 1516px | 400 | Corps, conseil kiné |
| `label` | Mono | 1113px | 500 | Tags, metadata, uppercase tracking |
| `timer` | Mono | **80100px** | 500 | Timer séance — lisible à 2 mètres |
| `caption` | Sans | 12px | 400 | Sous-labels, hints |
**Règle typographie :** La taille du timer est la décision de design la plus
importante de l'écran séance. Tout se dimensionne autour de lui.
---
## 3. Espacement
Base : **4px**
| Token | Valeur | Usage |
|-------|--------|-------|
| `space-1` | 4px | Gap minimal entre éléments liés |
| `space-2` | 8px | Gap interne composant |
| `space-3` | 12px | Gap entre composants proches |
| `space-4` | 16px | Padding carte, gap standard |
| `space-6` | 24px | Espacement sections |
| `space-8` | 32px | Padding écran horizontal |
| `space-12` | 48px | Espacement majeur |
| `space-16` | 64px | Espacement entre blocs screens |
---
## 4. Border Radius
| Token | Valeur | Usage |
|-------|--------|-------|
| `radius-sm` | 4px | Badge, chip, tag |
| `radius-md` | 8px | Bouton, input, tip card |
| `radius-lg` | 12px | Carte programme standard |
| `radius-xl` | 16px | Carte large, modal |
| `radius-pill` | 9999px | Pill, toggle, progress bar |
| `radius-circle` | 50% | Icon button, avatar, streak dot |
---
## 5. Système d'élévation (surfaces)
```
Fond (navy-900)
└── Surface 1 (navy-800) — cartes par défaut
└── Surface 2 (navy-700) — cartes surélevées / hover
└── Surface active (navy-800 + border green-500 1.5px)
```
Différencier les surfaces **uniquement par la couleur de fond**, jamais par des
ombres portées (box-shadow : non). La bordure active verte est le seul signal
d'état sélectionné.
---
## 6. Composants
### Boutons
```
PrimaryButton
background: green-500
color: navy-900
padding: 14px 24px
height: 5256px
border-radius: radius-md
font: sans 15px 500
width: 100% (full-width dans les screens)
hover: background green-600
active: background green-700 + scale(0.98)
SecondaryButton
background: transparent
color: green-500
border: 1.5px solid green-500
padding: 13px 24px
hover: background green-dim
GhostButton
background: transparent
color: slate-300
no border
usage: actions secondaires (Passer, Annuler)
DangerButton
background: rgba(255,68,68,0.12)
color: #FF6B6B
border: 1px solid rgba(255,68,68,0.3)
usage: Quitter la séance UNIQUEMENT
IconButton
width: 44px
height: 44px
border-radius: 50%
background: rgba(168,178,216,0.10)
color: slate-300
JAMAIS en dessous de 44×44px (accessibilité)
```
### Inputs
```
TextField
background: navy-800
border: 1px solid border-dim
border-radius: radius-md
padding: 12px 16px
color: white-100
font: sans 15px 400
focus: border green-500
error: border red-500
height: 48px
```
### Badges & Pills
```
Badge (tier)
font: mono 11px 500
padding: 3px 10px
border-radius: radius-sm
UPPERCASE + letter-spacing: 0.08em
.free: background green-dim, color green-500
.premium: background orange-dim, color orange-500
.kine: background rgba(168,178,216,0.12), color slate-300
Pill (metadata)
font: sans 12px 400
padding: 4px 12px
border-radius: radius-pill
border: 1px solid (couleur correspondante à 0.3 opacity)
```
### Cartes
```
CardDefault
background: navy-800
border: 1px solid border-dim
border-radius: radius-lg
padding: 16px
CardAccent (CTA, prochaine séance)
background: rgba(0,200,150,0.05)
border: 1.5px solid green-border
border-radius: radius-lg
CardTip (conseil kiné)
background: orange-dim
border-left: 3px solid orange-500
border-radius: 0 radius-lg radius-lg 0
NE PAS arrondir le côté gauche (border-left unique)
Structure: icône 💡 + texte + signature "— Prénom, kiné"
CardProgram
border-radius: radius-xl
overflow: hidden
Thumbnail: 120px height, gradient navy-700→navy-600
Body: padding 14px
Toujours afficher: progression bar + "X/12 séances"
```
### Timer
```
Timer (composant le plus critique de l'app)
font: mono 80100px 500
text-align: center
État effort normal (>10s):
color: green-500
État urgence (<10s):
color: red-500
animation: pulse subtil (scale 1→1.02→1, 1s infinite)
Label sous le chiffre:
font: mono 14px 400
color: slate-400
letter-spacing: 0.1em
text: "SECONDES"
Contexte repos:
color: slate-300 (pas de vert, signal visuel de repos)
```
### Progress Bar
```
ProgressBar
track: background rgba(168,178,216,0.12), height 4px, border-radius pill
fill: background green-500, border-radius pill
Variante séance (épaisseur réduite):
height: 3px
Variante programme:
height: 4px
Afficher le % à droite en mono 11px green-500
Animation: transition width 300ms ease
```
### Feedback ressenti
```
FeedbackButton
width: flex (3 boutons égaux)
height: 72px
border-radius: radius-lg
background: navy-800
border: 1px solid border-dim
flex-direction: column
gap: 4px
Emoji: 28px
Label: sans 12px slate-400
État sélectionné:
border: 1.5px solid green-500
background: green-dim
```
### Streak hebdomadaire
```
StreakDot
width: 32px
height: 32px
border-radius: 50%
.done: background rgba(0,200,150,0.15) → afficher ✓
.today: background green-500 → afficher ✓
.empty: background rgba(168,178,216,0.06), border 1px border-dim
Label jour: mono 10px slate-400, centré sous chaque dot
```
---
## 7. Écran séance — règles spéciales
L'écran séance est le plus critique de l'app. Il doit être utilisable **les mains
sur les genoux, en sueur, à 2 mètres de l'écran**. Chaque décision de design doit
passer ce test.
### Architecture visuelle
```
[Vidéo plein écran en boucle — fond de tout l'écran]
↓ Gradient top navy→transparent (40% opacité, 100px height)
→ Contrôles pause/audio en overlay
→ Indicateur exercice X/8 centré
↓ Zone centrale nette (pas de gradient — l'utilisateur voit le mouvement)
↓ Gradient bottom transparent→navy (70% opacité, 220px height)
→ Timer géant centré
→ Progress bar
→ Tip card conseil kiné
```
### Transitions séance
```
Effort → Repos:
Fond passe de vidéo plein écran → navy-800 uni
Transition: fade 300ms
Vibration haptique légère (si disponible)
Le repos a une identité visuelle différente (pas de vidéo, couleur unie)
Repos → Effort:
Countdown audio "3... 2... 1..."
Vibration haptique + transition fade
Exercice suivant pendant le repos:
Afficher un thumbnail 56×56px du prochain exercice
Nom en sans 14px 500
Label "PROCHAIN EXERCICE" en mono 11px slate-400
```
### Phase repos
L'écran repos doit être **visuellement différent** de l'écran effort.
- Fond : `navy-800` uni (plus de vidéo plein écran)
- Timer couleur : `slate-300` (pas de vert — c'est le repos)
- Mot "REPOS" en mono 13px slate-400, letter-spacing 0.15em
- Aperçu prochain exercice centré
---
## 8. Navigation
```
Tab Bar (5 onglets, fixé en bas)
height: 56px + safe area inset
background: navy-800
border-top: 1px solid border-dim
Onglets:
- Accueil (home icon)
- Programmes (grid icon)
- Minuteur (timer icon)
- Progression (chart icon)
- Profil (person icon)
Onglet actif: icône green-500 + label green-500
Onglet inactif: icône slate-400 + label slate-400
Font label: sans 11px 400
Icon size: 22×22px
Touch target: 44×44px minimum
```
---
## 9. Animations & micro-interactions
```
FadeIn:
opacity: 0 → 1
duration: 300ms
easing: ease
SlideUp (bottom sheet, modal):
translateY(100%) → 0
duration: 400ms
easing: cubic-bezier(0.4, 0, 0.2, 1)
Pulse (CTA bouton, timer urgence):
scale: 1 → 1.02 → 1
duration: 2s
infinite, ease-in-out
Bounce (célébration fin de séance):
scale: 0.5 → 1.05 → 0.98 → 1
duration: 600ms
easing: spring
StaggerList (items qui apparaissent en séquence):
Délai: 100ms entre chaque item
Chaque item: FadeIn + translateY(12px→0)
ScalePress (tous les boutons):
active: scale(0.97)
duration: 100ms
```
**Règle d'or animations :** Une animation bien exécutée au chargement d'écran
vaut mieux que des micro-interactions dispersées partout.
---
## 10. Paywall — règles de design conversion
```
Structure obligatoire du paywall:
1. Célébration des accomplissements (TOUJOURS en premier)
→ Font serif italic, emoji, stats concrètes
2. Valeur du contenu débloqué (liste concrète)
3. Pricing transparent (pas de dark patterns)
→ "Essai gratuit 7 jours · puis 24,99€/an · soit 2,08€/mois"
4. CTA principal (PrimaryButton full-width)
5. Réassurance ("Annulation facile à tout moment")
6. Alternative gratuite visible (GhostButton ou lien)
Couleur encadré pricing: CardAccent (vert)
Bouton fermeture: TOUJOURS visible en haut à gauche
Pas de compte à rebours fictif, pas de stock limité : anti dark patterns
```
---
## 11. Accessibilité
```
Contraste texte:
Texte primaire (#E6F1FF) sur navy-900 → ratio 15:1 ✓
Texte secondaire (#A8B2D8) sur navy-900 → ratio 7:1 ✓
Vert (#00C896) sur navy-900 → ratio 8:1 ✓
Tous conformes WCAG AA (4.5:1 minimum requis)
Touch targets:
Minimum 44×44px pour TOUS les éléments interactifs
Espacement minimum 8px entre deux éléments interactifs adjacents
Timer:
La couleur n'est pas le seul signal d'urgence
Ajouter aussi: pulse animation + vibration haptique + signal audio
Audio:
Toujours proposer une alternative visuelle à chaque signal audio
Le toggle audio est accessible en 1 tap depuis l'écran séance
```
---
## 12. Tokens React Native / Expo
```typescript
// design-tokens.ts
export const colors = {
// Navy
navy900: '#0D1B2A',
navy800: '#112240',
navy700: '#1A3050',
navy600: '#243C5E',
// Green
green500: '#00C896',
green600: '#00A67C',
green700: '#00875F',
greenDim: 'rgba(0,200,150,0.12)',
greenBorder: 'rgba(0,200,150,0.35)',
// Text
white100: '#E6F1FF',
slate300: '#A8B2D8',
slate400: '#8892B0',
// Borders
borderDim: 'rgba(168,178,216,0.15)',
borderHover: 'rgba(168,178,216,0.25)',
// Orange (tip/kine only)
orange500: '#FF8A5C',
orange600: '#E06A3C',
orangeDim: 'rgba(255,138,92,0.12)',
// Semantic
red500: '#FF4444', // timer urgence ONLY
} as const
export const spacing = {
1: 4,
2: 8,
3: 12,
4: 16,
6: 24,
8: 32,
12: 48,
16: 64,
} as const
export const radius = {
sm: 4,
md: 8,
lg: 12,
xl: 16,
pill: 9999,
} as const
export const fontSizes = {
caption: 12,
label: 13,
body: 15,
heading2: 18,
heading1: 22,
display: 28,
timer: 88, // taille par défaut du timer
} as const
export const timerThreshold = 10 // secondes — passage vert → rouge
```
---
## Checklist avant livraison d'un écran
- [ ] Fond `navy-900` utilisé comme base
- [ ] Aucun shadow/élévation — différenciation par couleur uniquement
- [ ] Tous les touch targets ≥ 44×44px
- [ ] Le vert n'est utilisé que pour des actions ou validations
- [ ] L'orange n'est utilisé que pour des conseils kiné ou alertes positives
- [ ] Le rouge n'apparaît que sur le timer en urgence
- [ ] Timer ≥ 80px de haut sur l'écran séance
- [ ] Vidéo plein écran sur l'écran séance (pas un bloc vidéo)
- [ ] Gradients top + bottom sur l'écran séance pour la lisibilité
- [ ] Phase repos visuellement différente de la phase effort
- [ ] Paywall : célébration en premier, alternative gratuite visible
- [ ] Typographie : serif pour les moments émotionnels, mono pour les données

View File

@@ -0,0 +1,82 @@
---
name: gitnexus-cli
description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\""
---
# GitNexus CLI Commands
All commands work via `npx` — no global install required.
## Commands
### analyze — Build or refresh the index
```bash
npx gitnexus analyze
```
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
| Flag | Effect |
| -------------- | ---------------------------------------------------------------- |
| `--force` | Force full re-index even if up to date |
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated.
### status — Check index freshness
```bash
npx gitnexus status
```
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
### clean — Delete the index
```bash
npx gitnexus clean
```
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
| Flag | Effect |
| --------- | ------------------------------------------------- |
| `--force` | Skip confirmation prompt |
| `--all` | Clean all indexed repos, not just the current one |
### wiki — Generate documentation from the graph
```bash
npx gitnexus wiki
```
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
| Flag | Effect |
| ------------------- | ----------------------------------------- |
| `--force` | Force full regeneration |
| `--model <model>` | LLM model (default: minimax/minimax-m2.5) |
| `--base-url <url>` | LLM API base URL |
| `--api-key <key>` | LLM API key |
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
| `--gist` | Publish wiki as a public GitHub Gist |
### list — Show all indexed repos
```bash
npx gitnexus list
```
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
## After Indexing
1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded
2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task
## Troubleshooting
- **"Not inside a git repository"**: Run from a directory inside a git repo
- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding

View File

@@ -0,0 +1,89 @@
---
name: gitnexus-debugging
description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\""
---
# Debugging with GitNexus
## When to Use
- "Why is this function failing?"
- "Trace where this error comes from"
- "Who calls this method?"
- "This endpoint returns 500"
- Investigating bugs, errors, or unexpected behavior
## Workflow
```
1. gitnexus_query({query: "<error or symptom>"}) → Find related execution flows
2. gitnexus_context({name: "<suspect>"}) → See callers/callees/processes
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
## Checklist
```
- [ ] Understand the symptom (error message, unexpected behavior)
- [ ] gitnexus_query for error text or related code
- [ ] Identify the suspect function from returned processes
- [ ] gitnexus_context to see callers and callees
- [ ] Trace execution flow via process resource if applicable
- [ ] gitnexus_cypher for custom call chain traces if needed
- [ ] Read source files to confirm root cause
```
## Debugging Patterns
| Symptom | GitNexus Approach |
| -------------------- | ---------------------------------------------------------- |
| Error message | `gitnexus_query` for error text → `context` on throw sites |
| Wrong return value | `context` on the function → trace callees for data flow |
| Intermittent failure | `context` → look for external calls, async deps |
| Performance issue | `context` → find symbols with many callers (hot paths) |
| Recent regression | `detect_changes` to see what your changes affect |
## Tools
**gitnexus_query** — find code related to error:
```
gitnexus_query({query: "payment validation error"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError, PaymentException
```
**gitnexus_context** — full context for a suspect:
```
gitnexus_context({name: "validatePayment"})
→ Incoming calls: processCheckout, webhookHandler
→ Outgoing calls: verifyCard, fetchRates (external API!)
→ Processes: CheckoutFlow (step 3/7)
```
**gitnexus_cypher** — custom call chain traces:
```cypher
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
RETURN [n IN nodes(path) | n.name] AS chain
```
## Example: "Payment endpoint returns 500 intermittently"
```
1. gitnexus_query({query: "payment error handling"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError
2. gitnexus_context({name: "validatePayment"})
→ Outgoing calls: verifyCard, fetchRates (external API!)
3. READ gitnexus://repo/my-app/process/CheckoutFlow
→ Step 3: validatePayment → calls fetchRates (external)
4. Root cause: fetchRates calls external API without proper timeout
```

View File

@@ -0,0 +1,78 @@
---
name: gitnexus-exploring
description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\""
---
# Exploring Codebases with GitNexus
## When to Use
- "How does authentication work?"
- "What's the project structure?"
- "Show me the main components"
- "Where is the database logic?"
- Understanding code you haven't seen before
## Workflow
```
1. READ gitnexus://repos → Discover indexed repos
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
3. gitnexus_query({query: "<what you want to understand>"}) → Find related execution flows
4. gitnexus_context({name: "<symbol>"}) → Deep dive on specific symbol
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
```
> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal.
## Checklist
```
- [ ] READ gitnexus://repo/{name}/context
- [ ] gitnexus_query for the concept you want to understand
- [ ] Review returned processes (execution flows)
- [ ] gitnexus_context on key symbols for callers/callees
- [ ] READ process resource for full execution traces
- [ ] Read source files for implementation details
```
## Resources
| Resource | What you get |
| --------------------------------------- | ------------------------------------------------------- |
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
## Tools
**gitnexus_query** — find execution flows related to a concept:
```
gitnexus_query({query: "payment processing"})
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Symbols grouped by flow with file locations
```
**gitnexus_context** — 360-degree view of a symbol:
```
gitnexus_context({name: "validateUser"})
→ Incoming calls: loginHandler, apiMiddleware
→ Outgoing calls: checkToken, getUserById
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
```
## Example: "How does payment processing work?"
```
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
2. gitnexus_query({query: "payment processing"})
→ CheckoutFlow: processPayment → validateCard → chargeStripe
→ RefundFlow: initiateRefund → calculateRefund → processRefund
3. gitnexus_context({name: "processPayment"})
→ Incoming: checkoutHandler, webhookHandler
→ Outgoing: validateCard, chargeStripe, saveTransaction
4. Read src/payments/processor.ts for implementation details
```

View File

@@ -0,0 +1,64 @@
---
name: gitnexus-guide
description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\""
---
# GitNexus Guide
Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema.
## Always Start Here
For any task involving code understanding, debugging, impact analysis, or refactoring:
1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
2. **Match your task to a skill below** and **read that skill file**
3. **Follow the skill's workflow and checklist**
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
## Skills
| Task | Skill to read |
| -------------------------------------------- | ------------------- |
| Understand architecture / "How does X work?" | `gitnexus-exploring` |
| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` |
| Trace bugs / "Why is X failing?" | `gitnexus-debugging` |
| Rename / extract / split / refactor | `gitnexus-refactoring` |
| Tools, resources, schema reference | `gitnexus-guide` (this file) |
| Index, status, clean, wiki CLI commands | `gitnexus-cli` |
## Tools Reference
| Tool | What it gives you |
| ---------------- | ------------------------------------------------------------------------ |
| `query` | Process-grouped code intelligence — execution flows related to a concept |
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
| `detect_changes` | Git-diff impact — what do your current changes affect |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `list_repos` | Discover indexed repos |
## Resources Reference
Lightweight reads (~100-500 tokens) for navigation:
| Resource | Content |
| ---------------------------------------------- | ----------------------------------------- |
| `gitnexus://repo/{name}/context` | Stats, staleness check |
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores |
| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members |
| `gitnexus://repo/{name}/processes` | All execution flows |
| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace |
| `gitnexus://repo/{name}/schema` | Graph schema for Cypher |
## Graph Schema
**Nodes:** File, Function, Class, Interface, Method, Community, Process
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
```cypher
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
RETURN caller.name, caller.filePath
```

View File

@@ -0,0 +1,97 @@
---
name: gitnexus-impact-analysis
description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\""
---
# Impact Analysis with GitNexus
## When to Use
- "Is it safe to change this function?"
- "What will break if I modify X?"
- "Show me the blast radius"
- "Who uses this code?"
- Before making non-trivial code changes
- Before committing — to understand what your changes affect
## Workflow
```
1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
3. gitnexus_detect_changes() → Map current git changes to affected flows
4. Assess risk and report to user
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
## Checklist
```
- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
- [ ] Review d=1 items first (these WILL BREAK)
- [ ] Check high-confidence (>0.8) dependencies
- [ ] READ processes to check affected execution flows
- [ ] gitnexus_detect_changes() for pre-commit check
- [ ] Assess risk level and report to user
```
## Understanding Output
| Depth | Risk Level | Meaning |
| ----- | ---------------- | ------------------------ |
| d=1 | **WILL BREAK** | Direct callers/importers |
| d=2 | LIKELY AFFECTED | Indirect dependencies |
| d=3 | MAY NEED TESTING | Transitive effects |
## Risk Assessment
| Affected | Risk |
| ------------------------------ | -------- |
| <5 symbols, few processes | LOW |
| 5-15 symbols, 2-5 processes | MEDIUM |
| >15 symbols or many processes | HIGH |
| Critical path (auth, payments) | CRITICAL |
## Tools
**gitnexus_impact** — the primary tool for symbol blast radius:
```
gitnexus_impact({
target: "validateUser",
direction: "upstream",
minConfidence: 0.8,
maxDepth: 3
})
→ d=1 (WILL BREAK):
- loginHandler (src/auth/login.ts:42) [CALLS, 100%]
- apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%]
→ d=2 (LIKELY AFFECTED):
- authRouter (src/routes/auth.ts:22) [CALLS, 95%]
```
**gitnexus_detect_changes** — git-diff based impact analysis:
```
gitnexus_detect_changes({scope: "staged"})
→ Changed: 5 symbols in 3 files
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
→ Risk: MEDIUM
```
## Example: "What breaks if I change validateUser?"
```
1. gitnexus_impact({target: "validateUser", direction: "upstream"})
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
2. READ gitnexus://repo/my-app/processes
→ LoginFlow and TokenRefresh touch validateUser
3. Risk: 2 direct callers, 2 processes = MEDIUM
```

View File

@@ -0,0 +1,121 @@
---
name: gitnexus-refactoring
description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\""
---
# Refactoring with GitNexus
## When to Use
- "Rename this function safely"
- "Extract this into a module"
- "Split this service"
- "Move this to a new file"
- Any task involving renaming, extracting, splitting, or restructuring code
## Workflow
```
1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
2. gitnexus_query({query: "X"}) → Find execution flows involving X
3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
4. Plan update order: interfaces → implementations → callers → tests
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
## Checklists
### Rename Symbol
```
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
- [ ] gitnexus_detect_changes() — verify only expected files changed
- [ ] Run tests for affected processes
```
### Extract Module
```
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
- [ ] Define new module interface
- [ ] Extract code, update imports
- [ ] gitnexus_detect_changes() — verify affected scope
- [ ] Run tests for affected processes
```
### Split Function/Service
```
- [ ] gitnexus_context({name: target}) — understand all callees
- [ ] Group callees by responsibility
- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
- [ ] Create new functions/services
- [ ] Update callers
- [ ] gitnexus_detect_changes() — verify affected scope
- [ ] Run tests for affected processes
```
## Tools
**gitnexus_rename** — automated multi-file rename:
```
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
→ 12 edits across 8 files
→ 10 graph edits (high confidence), 2 ast_search edits (review)
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
```
**gitnexus_impact** — map all dependents first:
```
gitnexus_impact({target: "validateUser", direction: "upstream"})
→ d=1: loginHandler, apiMiddleware, testUtils
→ Affected Processes: LoginFlow, TokenRefresh
```
**gitnexus_detect_changes** — verify your changes after refactoring:
```
gitnexus_detect_changes({scope: "all"})
→ Changed: 8 files, 12 symbols
→ Affected processes: LoginFlow, TokenRefresh
→ Risk: MEDIUM
```
**gitnexus_cypher** — custom reference queries:
```cypher
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
RETURN caller.name, caller.filePath ORDER BY caller.filePath
```
## Risk Rules
| Risk Factor | Mitigation |
| ------------------- | ----------------------------------------- |
| Many callers (>5) | Use gitnexus_rename for automated updates |
| Cross-area refs | Use detect_changes after to verify scope |
| String/dynamic refs | gitnexus_query to find them |
| External/public API | Version and deprecate properly |
## Example: Rename `validateUser` to `authenticateUser`
```
1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
→ 12 edits: 10 graph (safe), 2 ast_search (review)
→ Files: validator.ts, login.ts, middleware.ts, config.json...
2. Review ast_search edits (config.json: dynamic reference!)
3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
→ Applied 12 edits across 8 files
4. gitnexus_detect_changes({scope: "all"})
→ Affected: LoginFlow, TokenRefresh
→ Risk: MEDIUM — run tests for these flows
```

4
.env
View File

@@ -1,4 +0,0 @@
# TabataFit Environment Variables
# Supabase Configuration
EXPO_PUBLIC_SUPABASE_URL=https://supabase.1000co.fr
EXPO_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzcyMjMzMjAwLCJleHAiOjE5Mjk5OTk2MDB9.SlYN046eGvUSObW0tFQHcMRUqFvtMqBLfFRlZliSx_w

View File

@@ -1,13 +0,0 @@
# TabataFit Environment Variables
# Copy this file to .env and fill in your credentials
# Supabase Configuration
EXPO_PUBLIC_SUPABASE_URL=your_supabase_project_url
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
# RevenueCat (Apple subscriptions)
# Defaults to test_ sandbox key if not set
EXPO_PUBLIC_REVENUECAT_API_KEY=your_revenuecat_api_key
# Admin Dashboard (optional - for admin authentication)
EXPO_PUBLIC_ADMIN_EMAIL=admin@tabatafit.app

2
.gitignore vendored
View File

@@ -52,3 +52,5 @@ coverage/
# Node compile cache
node-compile-cache/
.gitnexus
Config/Secrets.xcconfig

View File

@@ -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 (812 séances) | `[x]` | P0 | `src/shared/data/tabata/debutant.ts` |
| F-047 | Haut du corps — Intermédiaire (812 séances) | `[x]` | P0 | `src/shared/data/tabata/intermediaire.ts` |
| F-048 | Haut du corps — Avancé (812 séances) | `[x]` | P1 | `src/shared/data/tabata/avance.ts` |
| F-049 | Bas du corps — Débutant (812 séances) | `[x]` | P0 | Dans les data files tabata |
| F-050 | Bas du corps — Intermédiaire (812 séances) | `[x]` | P0 | Dans les data files tabata |
| F-051 | Bas du corps — Avancé (812 séances) | `[x]` | P1 | Dans les data files tabata |
| F-052 | Corps complet — Débutant (812 séances) | `[x]` | P0 | Dans les data files tabata |
| F-053 | Corps complet — Intermédiaire (812 séances) | `[x]` | P0 | Dans les data files tabata |
| F-054 | Corps complet — Avancé (812 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 |

102
AGENTS.md
View File

@@ -445,3 +445,105 @@ Search results can flood context. Use `context-mode_ctx_execute(language: "shell
| `ctx stats` | Call the `stats` MCP tool and display the full output verbatim |
| `ctx doctor` | Call the `doctor` MCP tool, run the returned shell command, display as checklist |
| `ctx upgrade` | Call the `upgrade` MCP tool, run the returned shell command, display as checklist |
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relationships, 52 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
## When Debugging
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/tabatago/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/tabatago/context` | Codebase overview, check index freshness |
| `gitnexus://repo/tabatago/clusters` | All functional areas |
| `gitnexus://repo/tabatago/processes` | All execution flows |
| `gitnexus://repo/tabatago/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->

108
CLAUDE.md
View File

@@ -192,11 +192,13 @@ COMPLETE: '#30D158' // Green
## 🚀 Commands
Use `rtk` (token-optimized CLI proxy) for all non-interactive commands.
```bash
npx expo start # Development
npx expo start # Development (interactive, no rtk)
npx expo start --tunnel # If network issues
npx expo start --clear # Clear cache
npx tsc --noEmit # Type check
rtk tsc --noEmit # Type check (grouped errors)
eas build --profile dev # Dev build
```
@@ -211,3 +213,105 @@ Voir `.claude/skills/` pour les guides spécialisés.
*Document updated: February 18, 2026*
*Version: 2.0*
*Project: TabataFit — Apple Fitness+ for Tabata*
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relationships, 52 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
## When Debugging
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/tabatago/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/tabatago/context` | Codebase overview, check index freshness |
| `gitnexus://repo/tabatago/clusters` | All functional areas |
| `gitnexus://repo/tabatago/processes` | All execution flows |
| `gitnexus://repo/tabatago/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->

112
README.md
View File

@@ -1,112 +0,0 @@
# TabataFit
> **Apple Fitness+ for Tabata** — The Premium HIIT Experience
![Expo](https://img.shields.io/badge/Expo-52-black)
![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue)
![License](https://img.shields.io/badge/License-Proprietary-red)
![Tests](https://img.shields.io/badge/Tests-546%20passing-brightgreen)
![Coverage](https://img.shields.io/badge/Coverage-Statements%20%7C%20Branches%20%7C%20Functions%20%7C%20Lines-blue)
## Vision
TabataFit est l'Apple Fitness+ du Tabata. Une expérience premium, video-first, guidée par des coachs, qui transforme 4 minutes d'exercice en une expérience de fitness immersive.
## Features
- 🎬 **Video-led workouts** — HD video demonstrations by professional trainers
- ⏱️ **Smart timer** — Tabata timer with work/rest phases
- 🔥 **Burn Bar** — Compare your calories with the community
- 📊 **Activity tracking** — Streaks, stats, and trends
- 🎵 **Music sync** — Curated playlists for each workout
-**Apple Watch** — Heart rate and activity rings
## Tech Stack
- **Framework**: Expo SDK 52
- **Navigation**: Expo Router v3
- **State**: Zustand
- **Video**: expo-av (HLS streaming)
- **Payments**: RevenueCat
- **Analytics**: PostHog
## Getting Started
```bash
# Install dependencies
npm install
# Start development server
npx expo start
# Run on device (scan QR with Expo Go)
```
## Documentation
| Document | Description |
|----------|-------------|
| [PRD v2.0](./TabataFit_PRD_v2.0.md) | Product Requirements |
| [PDD v2.0](./TabataFit_PDD_v2.0.md) | Product Design |
| [BDSD v2.0](./TabataFit_BDSD_v2.0.md) | Brand Design |
## Project Structure
```
src/
features/
home/ # Home tab
workouts/ # Workouts browser
player/ # Video player + timer
activity/ # Stats & history
browse/ # Filters & trainers
profile/ # User settings
shared/
components/ # Reusable UI
hooks/ # Custom hooks
constants/ # Design tokens
app/ # Expo Router routes
```
## Testing
```bash
# Unit tests with coverage
npm run test:coverage
# Component render tests
npm run test:render
# All unit + render tests
npm test && npm run test:render
# Maestro E2E (requires Expo dev server + simulator)
npm run test:maestro
# Admin-web tests
cd admin-web && npm test # Unit tests
cd admin-web && npm run test:e2e # Playwright E2E
```
### Test Coverage
| Layer | Target | Tests |
|-------|--------|-------|
| Stores | 80%+ | playerStore, activityStore, userStore, programStore |
| Services | 80%+ | analytics, music, purchases, sync |
| Hooks | 70%+ | useTimer, useHaptics, useAudio, usePurchases, useMusicPlayer, useNotifications, useSupabaseData |
| Components | 50%+ | StyledText, VideoPlayer, WorkoutCard, GlassCard, CollectionCard, modals, Skeleton |
| Data | 80%+ | achievements, collections, programs, trainers, workouts |
### E2E Tests
- **Mobile (Maestro)**: Onboarding, tab navigation, program browse, workout player, activity, profile/settings
- **Admin Web (Playwright)**: Auth, navigation, workouts CRUD, trainers, collections
## License
Proprietary — All rights reserved.
---
Built with ❤️ for HIIT lovers

View File

@@ -1,182 +0,0 @@
# Supabase Music Storage Setup
This guide walks you through setting up the Supabase Storage bucket for music tracks in TabataFit.
## Overview
TabataFit loads background music from Supabase Storage based on workout `musicVibe` values. The music service organizes tracks by vibe folders.
## Step 1: Create the Storage Bucket
1. Go to your Supabase Dashboard → Storage
2. Click **New bucket**
3. Name: `music`
4. Enable **Public access** (tracks are streamed to authenticated users)
5. Click **Create bucket**
## Step 2: Set Up Folder Structure
Create folders for each music vibe:
```
music/
├── electronic/
├── hip-hop/
├── pop/
├── rock/
└── chill/
```
### Via Dashboard:
1. Open the `music` bucket
2. Click **New folder** for each vibe
3. Name folders exactly as the `MusicVibe` type values
### Via SQL (optional):
```sql
-- Storage folders are virtual in Supabase
-- Just upload files with path prefixes like "electronic/track.mp3"
```
## Step 3: Upload Music Tracks
### Supported Formats
- MP3 (recommended)
- M4A (AAC)
- OGG (if needed)
### File Naming Convention
Name files with artist and title for best results:
```
Artist Name - Track Title.mp3
```
Examples:
```
Neon Dreams - Energy Pulse.mp3
Urban Flow - Street Heat.mp3
The Popstars - Summer Energy.mp3
```
### Upload via Dashboard:
1. Open a vibe folder (e.g., `electronic/`)
2. Click **Upload files**
3. Select your audio files
4. Ensure the path shows `music/electronic/`
### Upload via CLI:
```bash
# Install Supabase CLI if not already installed
npm install -g supabase
# Login
supabase login
# Link your project
supabase link --project-ref your-project-ref
# Upload tracks
supabase storage upload music/electronic/ "path/to/your/tracks/*.mp3"
```
## Step 4: Configure Storage Policies
### RLS Policy for Authenticated Users
Go to Supabase Dashboard → Storage → Policies → `music` bucket
Add these policies:
#### 1. Select Policy (Read Access)
```sql
CREATE POLICY "Allow authenticated users to read music"
ON storage.objects FOR SELECT
TO authenticated
USING (bucket_id = 'music');
```
#### 2. Insert Policy (Admin Upload Only)
```sql
CREATE POLICY "Allow admin uploads"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'music'
AND (auth.jwt() ->> 'role') = 'admin'
);
```
### Via Dashboard UI:
1. Go to **Storage****Policies**
2. Under `music` bucket, click **New policy**
3. Select **For SELECT** (get)
4. Allowed operation: **Authenticated**
5. Policy definition: `true` (or custom check)
## Step 5: Test the Setup
1. Start your Expo app: `npx expo start`
2. Start a workout with music enabled
3. Check console logs for:
```
[Music] Loaded X tracks for vibe: electronic
```
### Troubleshooting
**No tracks loading:**
- Check Supabase credentials in `.env`
- Verify folder names match `MusicVibe` type exactly
- Check Storage RLS policies allow read access
**Tracks not playing:**
- Ensure files are accessible (try signed URL in browser)
- Check audio format is supported by expo-av
- Verify CORS settings in Supabase
**CORS errors:**
Add to Supabase Dashboard → Settings → API → CORS:
```
app://*
http://localhost:8081
```
## Step 6: Environment Variables
Ensure your `.env` file has:
```env
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
```
## Music Vibes Reference
The app supports these vibe categories:
| Vibe | Description | Typical BPM |
|------|-------------|-------------|
| `electronic` | EDM, House, Techno | 128-140 |
| `hip-hop` | Rap, Trap, Beats | 85-110 |
| `pop` | Pop hits, Dance-pop | 100-130 |
| `rock` | Rock, Alternative | 120-160 |
| `chill` | Lo-fi, Ambient, Downtempo | 60-90 |
## Best Practices
1. **Track Duration**: 3-5 minutes ideal for Tabata workouts
2. **File Size**: Keep under 10MB for faster loading
3. **Bitrate**: 128-192kbps MP3 for good quality/size balance
4. **Loudness**: Normalize tracks to similar levels (-14 LUFS)
5. **Metadata**: Include ID3 tags for artist/title info
## Alternative: Local Development
If Supabase is not configured, the app uses mock tracks automatically. To force mock data, temporarily set invalid Supabase credentials.
## Next Steps
- [ ] Upload initial track library (5-10 tracks per vibe)
- [ ] Test on physical device
- [ ] Consider CDN for production scale
- [ ] Implement track favoriting/personal playlists

View File

@@ -1,228 +0,0 @@
# TabataFit Supabase Integration
This document explains how to set up and use the Supabase backend for TabataFit.
## Overview
TabataFit now uses Supabase as its backend for:
- **Database**: Storing workouts, trainers, collections, programs, and achievements
- **Storage**: Managing video files, thumbnails, and trainer avatars
- **Authentication**: Admin dashboard access control
- **Real-time**: Future support for live features
## Setup Instructions
### 1. Create Supabase Project
1. Go to [Supabase Dashboard](https://app.supabase.com)
2. Create a new project
3. Note your project URL and anon key
### 2. Configure Environment Variables
Create a `.env` file in the project root:
```bash
EXPO_PUBLIC_SUPABASE_URL=your_supabase_project_url
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
```
### 3. Run Database Migrations
1. Go to your Supabase project's SQL Editor
2. Open `supabase/migrations/001_initial_schema.sql`
3. Run the entire script
This creates:
- All necessary tables (workouts, trainers, collections, programs, achievements)
- Storage buckets (videos, thumbnails, avatars)
- Row Level Security policies
- Triggers for auto-updating timestamps
### 4. Seed the Database
Run the seed script to populate your database with initial data:
```bash
npx ts-node supabase/seed.ts
```
This will import all 50 workouts, 5 trainers, 6 collections, 3 programs, and 8 achievements.
### 5. Set Up Admin User
1. Go to Supabase Dashboard → Authentication → Users
2. Create a new user with email/password
3. Go to SQL Editor and run:
```sql
INSERT INTO admin_users (id, email, role)
VALUES ('USER_UUID_HERE', 'admin@example.com', 'admin');
```
Replace `USER_UUID_HERE` with the actual user UUID from step 2.
### 6. Configure Storage
In Supabase Dashboard → Storage:
1. Verify buckets exist: `videos`, `thumbnails`, `avatars`
2. Set bucket privacy to public for all three
3. Configure CORS if needed for your domain
## Architecture
### Data Flow
```
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
│ React Native │────▶│ Supabase │────▶│ PostgreSQL │
│ Client │ │ Client │ │ Database │
└─────────────────┘ └──────────────┘ └─────────────────┘
│ ┌──────────────┐
└──────────────▶│ Admin │
│ Dashboard │
└──────────────┘
```
### Key Components
1. **Supabase Client** (`src/shared/supabase/`)
- `client.ts`: Configured Supabase client
- `database.types.ts`: TypeScript definitions for tables
2. **Data Service** (`src/shared/data/dataService.ts`)
- `SupabaseDataService`: Handles all database operations
- Falls back to mock data if Supabase is not configured
3. **React Hooks** (`src/shared/hooks/useSupabaseData.ts`)
- `useWorkouts`, `useTrainers`, `useCollections`, etc.
- Automatic loading states and error handling
4. **Admin Service** (`src/admin/services/adminService.ts`)
- CRUD operations for content management
- File upload/delete for storage
- Admin authentication
5. **Admin Dashboard** (`app/admin/`)
- `/admin/login`: Authentication screen
- `/admin`: Main dashboard with stats
- `/admin/workouts`: Manage workouts
- `/admin/trainers`: Manage trainers
- `/admin/collections`: Manage collections
- `/admin/media`: Storage management
## Database Schema
### Tables
| Table | Description |
|-------|-------------|
| `trainers` | Trainer profiles with colors and avatars |
| `workouts` | Workout definitions with exercises and metadata |
| `collections` | Curated workout collections |
| `collection_workouts` | Many-to-many link between collections and workouts |
| `programs` | Multi-week workout programs |
| `program_workouts` | Link between programs and workouts with week/day |
| `achievements` | User achievement definitions |
| `admin_users` | Admin dashboard access control |
### Storage Buckets
| Bucket | Purpose | Access |
|--------|---------|--------|
| `videos` | Workout videos | Public read, Admin write |
| `thumbnails` | Workout thumbnails | Public read, Admin write |
| `avatars` | Trainer avatars | Public read, Admin write |
## Using the App
### As a User
The app works seamlessly:
- If Supabase is configured: Loads data from the cloud
- If not configured: Falls back to local mock data
### As an Admin
1. Navigate to `/admin` in the app
2. Sign in with your admin credentials
3. Manage content through the dashboard:
- View all workouts, trainers, collections
- Delete items (create/edit coming soon)
- Upload media files
## Development
### Adding New Workouts
```typescript
import { adminService } from '@/src/admin/services/adminService'
await adminService.createWorkout({
title: 'New Workout',
trainer_id: 'emma',
category: 'full-body',
level: 'Beginner',
duration: 4,
calories: 45,
rounds: 8,
prep_time: 10,
work_time: 20,
rest_time: 10,
equipment: ['No equipment'],
music_vibe: 'electronic',
exercises: [
{ name: 'Jumping Jacks', duration: 20 },
// ...
],
})
```
### Uploading Media
```typescript
const videoUrl = await adminService.uploadVideo(file, 'workout-1.mp4')
const thumbnailUrl = await adminService.uploadThumbnail(file, 'workout-1.jpg')
```
## Troubleshooting
### "Supabase is not configured" Warning
This is expected if environment variables are not set. The app will use mock data.
### Authentication Errors
1. Verify admin user exists in `admin_users` table
2. Check that email/password match
3. Ensure user is confirmed in Supabase Auth
### Storage Upload Failures
1. Verify storage buckets exist
2. Check RLS policies allow admin uploads
3. Ensure file size is within limits
### Data Not Syncing
1. Check network connection
2. Verify Supabase URL and key are correct
3. Check browser console for errors
## Security Considerations
- Row Level Security (RLS) is enabled on all tables
- Public can only read content
- Only admin users can write content
- Storage buckets have public read access
- Storage uploads restricted to admin users
## Future Enhancements
- [ ] Real-time workout tracking
- [ ] User progress sync across devices
- [ ] Offline support with local caching
- [ ] Push notifications
- [ ] Analytics and user insights

View File

@@ -1,594 +0,0 @@
# TabataFit — Brand Design Specification v2.0
> Apple Liquid Glass Design for Tabata
---
## Brand Positioning
**TabataFit = Apple Fitness+ avec Liquid Glass (iOS 18.4)**
| Attribute | Description |
|-----------|-------------|
| **Analogy** | "If Apple made a Tabata app in 2026" |
| **Vibe** | Premium, glassy, fluid, immersive |
| **Emotion** | Confident, empowering, sleek |
| **Differentiator** | Video-led + Liquid Glass UI |
---
## 🪟 Liquid Glass System (iOS 18.4 Style)
### Concept
Le **Liquid Glass** est le nouveau design language d'Apple qui combine :
- **Glassmorphism avancé** — Blur dynamique, transparence multicouche
- **Fluidité organique** — Formes arrondies, animations liquides
- **Lumière réactive** — Reflets, glows qui répondent au contenu
- **Profondeur atmosphérique** — Layers de verre empilés
### Glass Layers
```typescript
const GLASS = {
// Base glass (surfaces)
BASE: {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
backdropFilter: 'blur(40px)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.3,
shadowRadius: 32,
},
// Elevated glass (cards, modals)
ELEVATED: {
backgroundColor: 'rgba(255, 255, 255, 0.08)',
backdropFilter: 'blur(60px)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.15)',
shadowColor: '#000',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.4,
shadowRadius: 48,
},
// Inset glass (input fields, controls)
INSET: {
backgroundColor: 'rgba(0, 0, 0, 0.2)',
backdropFilter: 'blur(20px)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.05)',
},
// Tinted glass (accent overlays)
TINTED: {
backgroundColor: 'rgba(255, 107, 53, 0.15)', // Brand tint
backdropFilter: 'blur(40px)',
borderWidth: 1,
borderColor: 'rgba(255, 107, 53, 0.3)',
},
}
```
### Liquid Animations
```typescript
const LIQUID = {
// Morphing shapes
MORPH: {
duration: 600,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
},
// Ripple effect on tap
RIPPLE: {
scale: { from: 0.8, to: 1 },
opacity: { from: 0.5, to: 0 },
duration: 400,
},
// Breathing glow
BREATHE: {
scale: { from: 1, to: 1.02 },
shadowRadius: { from: 20, to: 30 },
duration: 2000,
loop: true,
},
// Slide with liquid easing
SLIDE: {
damping: 20,
stiffness: 300,
mass: 1,
},
}
```
---
## Visual Identity
### Logo Concept
```
┌─────────────────────────────┐
│ │
│ TABATA │ ← Bold, black
│ FIT │ ← Accent orange
│ │
│ [Flame icon] │ ← Optional mark
│ │
└─────────────────────────────┘
```
### Brand Voice
| DO | DON'T |
|----|-------|
| "Let's burn." | "Get ripped fast!" |
| "4 minutes to stronger." | "Lose weight now!" |
| "Your daily dose of HIIT." | "The #1 fitness app!" |
| Minimal, confident copy | Excessive exclamation marks!! |
---
## Color Palette
### Primary Colors
```
┌─────────────────────────────────────────────────────────┐
│ │
│ BLACK ████████████ #000000 Background │
│ CHARCOAL ████████████ #1C1C1E Surfaces │
│ SLATE ████████████ #2C2C2E Elevated │
│ │
│ FLAME ████████████ #FF6B35 Brand accent │
│ FLAME LIGHT ████████████ #FF8C5A Highlights │
│ │
│ ICE ████████████ #5AC8FA Rest phases │
│ GLOW ████████████ #FFD60A Achievement │
│ ENERGY ████████████ #30D158 Success/Complete│
│ │
└─────────────────────────────────────────────────────────┘
```
### Semantic Usage
| Color | Hex | Usage |
|-------|-----|-------|
| **Black** | #000000 | Main background, video frames |
| **Charcoal** | #1C1C1E | Cards, raised surfaces |
| **Slate** | #2C2C2E | Modals, elevated elements |
| **Flame** | #FF6B35 | Work phase, CTAs, brand |
| **Ice** | #5AC8FA | Rest phase, calm states |
| **Glow** | #FFD60A | Achievement badges, streaks |
| **Energy** | #30D158 | Complete states, success |
### Phase Colors (Critical)
```typescript
const PHASE_COLORS = {
PREP: '#FF9500', // Orange-yellow - get ready
WORK: '#FF6B35', // Flame orange - WORK!
REST: '#5AC8FA', // Ice blue - recover
COMPLETE: '#30D158', // Energy green - done!
}
```
---
## Typography
### Font Stack
**Primary**: Inter (Google Fonts)
- Clean, modern, excellent readability
- Variable weight support
- Apple SF Pro alternative
### Type Scale
| Style | Size | Weight | Use Case |
|-------|------|--------|----------|
| **HERO** | 48px | 900 | Marketing, celebration |
| **TITLE_1** | 34px | 700 | Screen titles |
| **TITLE_2** | 28px | 700 | Section headers |
| **TITLE_3** | 22px | 600 | Card titles |
| **BODY** | 17px | 400 | Default text |
| **BODY_BOLD** | 17px | 600 | Emphasis |
| **CAPTION** | 15px | 400 | Metadata |
| **MICRO** | 13px | 400 | Small labels |
| **TIMER** | 96px | 900 | Countdown display |
### Timer Typography (Special)
```typescript
const TIMER_STYLES = {
// Main countdown number
NUMBER: {
fontFamily: 'Inter_900Black',
fontSize: 96,
fontVariant: ['tabular-nums'], // Monospace digits
letterSpacing: -2,
},
// Phase label (WORK, REST)
PHASE: {
fontFamily: 'Inter_700Bold',
fontSize: 24,
letterSpacing: 2,
textTransform: 'uppercase',
},
// Round indicator
ROUND: {
fontFamily: 'Inter_500Medium',
fontSize: 17,
fontVariant: ['tabular-nums'],
},
}
```
---
## Spacing System
```typescript
const SPACING = {
// Base unit: 4px
0: 0,
1: 4,
2: 8,
3: 12,
4: 16,
5: 20,
6: 24,
8: 32,
10: 40,
12: 48,
16: 64,
// Semantic
XS: 4,
SM: 8,
MD: 16,
LG: 24,
XL: 32,
XXL: 48,
}
const LAYOUT = {
SCREEN_PADDING: 24, // Horizontal screen padding
CARD_RADIUS: 16, // Standard card radius
BUTTON_RADIUS: 12, // Button radius
TAB_BAR_HEIGHT: 80, // Bottom tab bar
STATUS_BAR: 44, // iOS status bar
}
```
---
## Component Styles
### Cards
```typescript
const CARD = {
CONTAINER: {
backgroundColor: '#1C1C1E',
borderRadius: 16,
overflow: 'hidden',
},
THUMBNAIL: {
aspectRatio: 16/9,
backgroundColor: '#2C2C2E',
},
CONTENT: {
padding: 16,
},
// Variants
FEATURED: {
borderRadius: 20,
},
COMPACT: {
flexDirection: 'row',
borderRadius: 12,
},
}
```
### Buttons
```typescript
const BUTTON = {
PRIMARY: {
backgroundColor: '#FF6B35',
paddingVertical: 16,
paddingHorizontal: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
PRIMARY_TEXT: {
color: '#FFFFFF',
fontFamily: 'Inter_600SemiBold',
fontSize: 17,
letterSpacing: 0.5,
},
SECONDARY: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '#3A3A3C',
},
GHOST: {
backgroundColor: 'transparent',
},
}
```
### Timer Display
```typescript
const TIMER = {
CONTAINER: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: 24,
background: 'linear-gradient(transparent, rgba(0,0,0,0.8))',
},
NUMBER: {
fontFamily: 'Inter_900Black',
fontSize: 96,
color: '#FFFFFF',
textAlign: 'center',
},
PROGRESS_BAR: {
height: 4,
backgroundColor: 'rgba(255,255,255,0.2)',
borderRadius: 2,
marginTop: 16,
},
PROGRESS_FILL: {
height: '100%',
borderRadius: 2,
// Color based on phase
},
}
```
---
## Animation Specifications
### Timing Constants
```typescript
const DURATION = {
INSTANT: 100,
FAST: 200,
NORMAL: 300,
SLOW: 500,
XSLow: 800,
}
const EASING = {
// Standard iOS ease
DEFAULT: 'ease-out',
// Spring animations
BOUNCY: {
damping: 15,
stiffness: 180,
},
GENTLE: {
damping: 20,
stiffness: 100,
},
}
```
### Key Animations
**Timer Countdown:**
```typescript
// Number pulses slightly each second
Animated.sequence([
Animated.timing(scale, { toValue: 1.05, duration: 100 }),
Animated.timing(scale, { toValue: 1, duration: 100 }),
])
```
**Progress Bar:**
```typescript
// Smooth linear fill during phase
Animated.timing(width, {
toValue: 100,
duration: phaseDuration,
easing: Easing.linear,
})
```
**Phase Transition:**
```typescript
// Color crossfade
Animated.timing(colorProgress, {
toValue: 1,
duration: 300,
})
```
**Workout Complete:**
```typescript
// Celebration with scale + fade
Animated.parallel([
Animated.spring(scale, { toValue: 1, ...BOUNCY }),
Animated.timing(opacity, { toValue: 1, duration: 500 }),
])
```
---
## Icon System
Using SF Symbols / Ionicons equivalent:
| Context | Icon | Size |
|---------|------|------|
| Home Tab | house.fill | 24 |
| Workouts Tab | flame.fill | 24 |
| Activity Tab | chart.bar.fill | 24 |
| Browse Tab | square.grid.2x2.fill | 24 |
| Profile Tab | person.fill | 24 |
| Play | play.fill | 20 |
| Pause | pause.fill | 20 |
| Close | xmark | 20 |
| Back | chevron.left | 20 |
| Forward | chevron.right | 20 |
| Heart | heart.fill | 20 |
| Search | magnifyingglass | 20 |
---
## Video Player UI
### Overlay Layout
```
┌─────────────────────────────────────────────┐
│ [Close X] [♥︎] [···] │ ← Top bar
│ │
│ │
│ [VIDEO CONTENT] │
│ │
│ │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ 🔥 WORK │ │
│ │ │ │
│ │ 00:14 │ │ ← Timer overlay
│ │ │ │
│ │ Round 3 of 8 │ │
│ │ ████████████░░░░ │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
```
### Video Requirements
- **Resolution**: 1080p minimum
- **Aspect**: 16:9 (landscape) or 9:16 (portrait mode)
- **Format**: HLS (m3u8) for streaming
- **Audio**: AAC, 128kbps minimum
- **Thumbnail**: JPG, same aspect ratio
---
## Sound Design
### Sound Effects
| Event | Sound | Description |
|-------|-------|-------------|
| Phase Start | "ding" | Quick, bright chime |
| Count 3-2-1 | "tick" | Subtle tick sound |
| Workout Start | "whoosh" | Energy whoosh |
| Workout Complete | "success" | Celebratory chime |
| Button Tap | "tap" | Soft tap feedback |
| Streak Achieved | "fire" | Crackling fire |
### Haptics
| Event | Haptic |
|-------|--------|
| Phase change | Medium |
| Button tap | Light |
| Countdown tick | Selection |
| Workout complete | Success |
| Error | Error |
---
## Dark Mode Only
TabataFit is **dark mode only** — no light mode. This is a deliberate design choice matching Apple Fitness+ and creating an immersive, cinematic experience.
Reasons:
1. Better video contrast
2. Less eye strain during workouts
3. Premium feel
4. Consistent with fitness studio lighting
---
## Image Guidelines
### Trainer Photos
- Professional, high-quality headshots
- Warm, approachable expressions
- Fitness attire visible
- Consistent lighting style
- Background: Dark or studio setting
### Workout Thumbnails
- Action shot from the workout
- Clear exercise demonstration
- Trainer visible
- Dark/gradient background
- Text overlay: Title, duration, level
### Collection Banners
- 16:9 aspect ratio
- Composite of trainer + exercises
- Gradient overlay for text
- Brand accent color elements
---
## Copy Guidelines
### Tone
- **Confident** but not arrogant
- **Encouraging** but not cheesy
- **Clear** and direct
- **Minimal** — let the content speak
### Examples
| Context | Good | Bad |
|---------|------|-----|
| CTA | "Start Workout" | "Start Your Workout Now!" |
| Empty state | "No workouts yet" | "You haven't done any workouts :(" |
| Error | "Connection lost" | "Oh no! Something went wrong!" |
| Success | "Workout complete" | "AMAZING! You crushed it!!!" |
### Coach Dialogue
- Motivational but authentic
- Form cues during exercises
- Breathing reminders during rest
- Encouragement without clichés
---
*Document created: February 18, 2026*
*Version: 2.0*
*Brand: Apple Fitness+ inspired*

View File

@@ -1,765 +0,0 @@
# TabataFit — Product Design Document v2.0
> Apple Fitness+ Design Language for Tabata
---
## Design Philosophy
**"Make it feel like a premium fitness studio in your pocket."**
TabataFit adopts le design language d'Apple Fitness+ :
- **Dark mode premium** — Fond noir profond, couleurs vibrantes
- **Video-first** — Le contenu est le héros
- **Typography bold** — Gros titres, textes épurés
- **Subtle animations** — Transitions fluides, feedback délicat
- **Inclusive imagery** — Diversité des coachs et body types
---
## Color System — Dark Premium
### Background Colors
```typescript
const COLORS = {
// Backgrounds
BACKGROUND: '#000000', // Pure black — comme Apple TV
SURFACE: '#1C1C1E', // Raised surfaces
ELEVATED: '#2C2C2E', // Cards, modals
OVERLAY: 'rgba(0,0,0,0.6)', // Video overlays
// Brand Accent (Vibrant Orange-Red)
BRAND: '#FF6B35', // Energy, action
BRAND_LIGHT: '#FF8C5A', // Highlights
BRAND_DARK: '#E55A25', // Pressed states
// Secondary Accents
SUCCESS: '#34C759', // Completed, streaks
WARNING: '#FF9500', // Rest phases
INFO: '#5AC8FA', // Tips, info
// Text
TEXT_PRIMARY: '#FFFFFF', // Main text
TEXT_SECONDARY: '#EBEBF5', // Secondary (87% opacity)
TEXT_TERTIARY: '#EBEBF599', // Tertiary (60% opacity)
TEXT_DISABLED: '#3A3A3C', // Disabled text
// Semantic
WORK: '#FF6B35', // Active work phase
REST: '#5AC8FA', // Rest phase (calm blue)
PREP: '#FF9500', // Countdown prep
}
```
### Gradient Presets
```typescript
const GRADIENTS = {
// Hero banners
HERO_WORK: ['#FF6B35', '#E55A25'],
HERO_REST: ['#5AC8FA', '#007AFF'],
HERO_FEAT: ['#1C1C1E', '#000000'],
// Video overlays
VIDEO_OVERLAY: ['transparent', 'rgba(0,0,0,0.8)'],
VIDEO_TOP: ['rgba(0,0,0,0.4)', 'transparent'],
// Buttons
CTA: ['#FF6B35', '#FF8C5A'],
}
```
---
## Typography System — Apple SF Pro Style
```typescript
const TYPOGRAPHY = {
// Hero/Display
HERO: {
fontFamily: 'Inter_900Black',
fontSize: 48,
lineHeight: 56,
letterSpacing: -1,
},
// Section Headers (like Apple Fitness+)
TITLE_1: {
fontFamily: 'Inter_700Bold',
fontSize: 34,
lineHeight: 41,
letterSpacing: 0.37,
},
TITLE_2: {
fontFamily: 'Inter_700Bold',
fontSize: 28,
lineHeight: 34,
letterSpacing: 0.36,
},
TITLE_3: {
fontFamily: 'Inter_600SemiBold',
fontSize: 22,
lineHeight: 28,
letterSpacing: 0.35,
},
// Body
BODY: {
fontFamily: 'Inter_400Regular',
fontSize: 17,
lineHeight: 22,
letterSpacing: -0.41,
},
BODY_BOLD: {
fontFamily: 'Inter_600SemiBold',
fontSize: 17,
lineHeight: 22,
letterSpacing: -0.41,
},
// Metadata
CAPTION_1: {
fontFamily: 'Inter_400Regular',
fontSize: 15,
lineHeight: 20,
letterSpacing: -0.24,
},
CAPTION_2: {
fontFamily: 'Inter_400Regular',
fontSize: 14,
lineHeight: 18,
letterSpacing: -0.15,
},
// Timer (special)
TIMER: {
fontFamily: 'Inter_900Black',
fontSize: 96,
lineHeight: 96,
letterSpacing: -2,
},
TIMER_PHASE: {
fontFamily: 'Inter_700Bold',
fontSize: 24,
lineHeight: 28,
letterSpacing: 2, // Uppercase tracking
textTransform: 'uppercase',
},
}
```
---
## Screen Designs
### 1. Home Tab — "For You"
#### Layout Structure
```
┌─────────────────────────────────────────────────────┐
│ status bar │
├─────────────────────────────────────────────────────┤
│ │
│ Bonjour, Alex [Profile] │ ← 24px padding
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ │ │
│ │ [VIDEO PREVIEW - LOOPING] │ │ ← Hero Card
│ │ │ │ 16:9 aspect
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ 🔥 FEATURED │ │ │
│ │ │ │ │ │
│ │ │ FULL BODY BURN │ │ │ ← Text overlay
│ │ │ 4 min • Beginner • Emma │ │ │ on video
│ │ │ │ │ │
│ │ │ [▶️ START] [♡ Save] │ │ │
│ │ └─────────────────────────────────┘ │ │
│ │ │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ Continue See All → │ ← Section header
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ [Thumb] │ │ [Thumb] │ │ [Thumb] │ │ ← Horizontal scroll
│ │ ━━━━━ │ │ ━━━━━ │ │ ━━━━━ │ │ 140x200px cards
│ │ 65% │ │ 30% │ │ 10% │ │
│ │ Core │ │ HIIT │ │ Full │ │
│ │ Burn │ │ Extreme │ │ Body │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ Popular This Week │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ [Thumb] │ │ [Thumb] │ │ [Thumb] │ │ [Thumb] │ │ ← Smaller cards
│ │ │ │ │ │ │ │ │ │ │ 120x120px
│ │ Quick │ │ Strength│ │ Cardio │ │ Core │ │
│ │ Burn │ │ Tabata │ │ Blast │ │ Crush │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ Collections │
│ ┌─────────────────────────────────────────────┐ │
│ │ 🌅 Morning Energizer │ │ ← Full-width cards
│ │ 5 workouts • 20 min total │ │
│ └─────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────┐ │
│ │ 🔥 7-Day Challenge │ │
│ │ 7 workouts • Progressive intensity │ │
│ └─────────────────────────────────────────────┘ │
│ │
├─────────────────────────────────────────────────────┤
│ [Home] [Workouts] [Activity] [Browse] [Profile] │
└─────────────────────────────────────────────────────┘
```
#### Component Specs
**Hero Card:**
- Full width - 48px padding
- 16:9 aspect ratio
- Video preview looping (muted)
- Gradient overlay: transparent → rgba(0,0,0,0.8)
- Featured badge top-left
- Title, metadata, CTA bottom
**Continue Watching Card:**
- 140px width × 200px height
- Thumbnail with progress bar overlay
- Progress percentage badge
- Workout name + duration
**Popular Card:**
- 120px × 120px square
- Thumbnail only
- Category name below
**Collection Card:**
- Full width - 48px padding
- 80px height
- Icon + title + description
- Chevron right
---
### 2. Workouts Tab
#### Layout Structure
```
┌─────────────────────────────────────────────────────┐
│ status bar │
├─────────────────────────────────────────────────────┤
│ │
│ Workouts [🔍 Search] │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ │ │
│ │ [LARGE CATEGORY THUMBNAIL] │ │
│ │ │ │
│ │ 🔥 QUICK BURN │ │
│ │ 4 min • All levels │ │
│ │ 12 workouts │ │
│ │ [→] │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ │ │
│ │ [LARGE CATEGORY THUMBNAIL] │ │
│ │ │ │
│ │ 💪 STRENGTH TABATA │ │
│ │ 8 min • Intermediate │ │
│ │ 8 workouts │ │
│ │ [→] │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ 🏃 CARDIO BLAST │ │
│ │ 4-12 min • All levels • 15 workouts [→] │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ 🧘 CORE & FLEXIBILITY │ │
│ │ 4 min • Beginner • 6 workouts [→] │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ ⚡ HIIT EXTREME │ │
│ │ 12-20 min • Advanced • 10 workouts [→] │ │
│ └───────────────────────────────────────────────┘ │
│ │
├─────────────────────────────────────────────────────┤
│ [Home] [Workouts] [Activity] [Browse] [Profile] │
└─────────────────────────────────────────────────────┘
```
#### Category Detail View
```
┌─────────────────────────────────────────────────────┐
│ ← Quick Burn │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ 4 min • All levels • 12 workouts │ │
│ │ │ │
│ │ Filter: [All] [Beginner] [Intermediate] │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ ┌──────┐ │ │
│ │ │[Vid] │ Full Body Ignite │ │
│ │ │ │ 4 min • Beginner • Emma │ │
│ │ └──────┘ │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ ┌──────┐ │ │
│ │ │[Vid] │ Cardio Crusher │ │
│ │ │ │ 4 min • Intermediate • Alex │ │
│ │ └──────┘ │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ ┌──────┐ │ │
│ │ │[Vid] │ Lower Body Blast │ │
│ │ │ │ 4 min • Intermediate • Jake │ │
│ │ └──────┘ │ │
│ └───────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────┘
```
---
### 3. Pre-Workout Detail Screen
```
┌─────────────────────────────────────────────────────┐
│ ← [♡] [···] │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ │ │
│ │ │ │
│ │ [VIDEO PREVIEW - LOOPING] │ │
│ │ │ │
│ │ Coach Emma in action │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ FULL BODY IGNITE │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ │
│ 👩 Emma • 💪 Beginner • ⏱️ 4 min • 🔥 45cal │
│ │
│ ───────────────────────────────────────────────── │
│ │
│ What You'll Need │
│ ○ No equipment required │
│ ○ Yoga mat optional │
│ │
│ ───────────────────────────────────────────────── │
│ │
│ Exercises (8 rounds) │
│ ┌─────────────────────────────────────────────┐ │
│ │ 1. Jump Squats 20s work │ │
│ │ 2. Mountain Climbers 20s work │ │
│ │ 3. Burpees 20s work │ │
│ │ 4. High Knees 20s work │ │
│ │ ─────────────────────── │ │
│ │ Repeat × 2 rounds │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ───────────────────────────────────────────────── │
│ │
│ Music │
│ 🎵 Electronic Energy │
│ Upbeat, high-energy electronic tracks │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ │ │
│ │ ▶️ START WORKOUT │ │
│ │ │ │
│ └───────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────┘
```
---
### 4. Active Workout Screen — The Core Experience
#### Work Phase
```
┌─────────────────────────────────────────────────────┐
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ [FULL SCREEN VIDEO] │ │
│ │ │ │
│ │ Coach doing Jump Squats │ │
│ │ in perfect form │ │
│ │ │ │
│ │ │ │
│ │ ┌───────────────────────────────────────┐ │ │
│ │ │ ┌─────────────────────────────────┐ │ │ │
│ │ │ │ 🔥 WORK │ │ │ │ ← Timer overlay
│ │ │ │ │ │ │ │ bottom gradient
│ │ │ │ 00:14 │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ Round 3 of 8 │ │ │ │
│ │ │ │ ████████████░░░░ 65% │ │ │ │
│ │ │ └─────────────────────────────────┘ │ │ │
│ │ └───────────────────────────────────────┘ │ │
│ │ │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ JUMP SQUATS │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ 52 │ │ 142 │ │ 85% │ │
│ │ CALORIES │ │ BPM │ │ EFFORT │ │
│ └───────────┘ └───────────┘ └───────────┘ │
│ │
│ Burn Bar │
│ ░░░░░░░░████████████████░░░░ 72nd percentile │
│ │
│ [⏸️ Pause] [⛶ Fullscreen]│
│ │
└─────────────────────────────────────────────────────┘
```
#### Rest Phase
```
┌─────────────────────────────────────────────────────┐
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ │ │
│ │ [COACH IN REST POSITION] │ │
│ │ │ │
│ │ "Shake it out, take a breath" │ │
│ │ │ │
│ │ ┌───────────────────────────────────────┐ │ │
│ │ │ ┌─────────────────────────────────┐ │ │ │
│ │ │ │ 💙 REST │ │ │ │ ← Blue rest theme
│ │ │ │ │ │ │ │
│ │ │ │ 00:08 │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ Next: Mountain Climbers │ │ │ │
│ │ │ │ ░░░░░░░░░░░░░░░░░░░ 40% │ │ │ │
│ │ │ └─────────────────────────────────┘ │ │ │
│ │ └───────────────────────────────────────┘ │ │
│ │ │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ UP NEXT │
│ ┌───────────────────────────────────────────────┐ │
│ │ ┌──────┐ │ │
│ │ │ GIF │ Mountain Climbers │ │
│ │ │preview│ "Core engaged, drive knees forward" │ │
│ │ └──────┘ │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ [⏸️ Pause] [⛶ Fullscreen]│
│ │
└─────────────────────────────────────────────────────┘
```
#### 3-2-1 Countdown (Pre-Work)
```
┌─────────────────────────────────────────────────────┐
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ GET READY! │ │
│ │ │ │
│ │ 3 │ │ ← Giant number
│ │ │ │ centered
│ │ │ │
│ │ JUMP SQUATS │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ └───────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────┘
```
---
### 5. Workout Complete Screen
```
┌─────────────────────────────────────────────────────┐
│ │
│ │
│ 🎉 │
│ │
│ WORKOUT COMPLETE │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ │ │
│ │ [ANIMATED CELEBRATION RINGS] │ │
│ │ │ │
│ │ 🔥 💪 ⚡ │ │
│ │ │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ 52 │ │ 4 │ │ 100% │ │
│ │ CALORIES │ │ MINUTES │ │ COMPLETE │ │
│ └───────────┘ └───────────┘ └───────────┘ │
│ │
│ Burn Bar │
│ You beat 73% of users! │
│ ░░░░░░░░████████████████░░░░ │
│ │
│ ───────────────────────────────────────────────── │
│ │
│ 🔥 7 Day Streak! │
│ Keep the momentum going! │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ 📤 SHARE YOUR WORKOUT │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ ← BACK TO HOME │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ Recommended Next │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ [Thumb] │ │ [Thumb] │ │ [Thumb] │ │
│ │ Core │ │ Upper │ │ Cardio │ │
│ │ Crush │ │ Body │ │ Blast │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
└─────────────────────────────────────────────────────┘
```
---
### 6. Activity Tab
```
┌─────────────────────────────────────────────────────┐
│ │
│ Activity │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ │ │
│ │ 🔥 STREAK │ │
│ │ │ │
│ │ 7 │ │
│ │ DAYS │ │
│ │ │ │
│ │ ● ● ● ● ● ● ● ○ ○ ○ │ │
│ │ M T W T F S S M T W │ │
│ │ │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ This Week │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ 5 │ │ 156 │ │ 20 │ │
│ │ WORKOUTS │ │ CALORIES │ │ MINUTES │ │
│ │ 5 goal │ │ 150 goal │ │ 20 goal │ │
│ └───────────┘ └───────────┘ └───────────┘ │
│ │
│ Monthly Summary │
│ ┌───────────────────────────────────────────────┐ │
│ │ [CALENDAR HEAT MAP] │ │
│ │ │ │
│ │ Jan 2026 │ │
│ │ S M T W T F S │ │
│ │ 1 2 3 4 5 6 │ │
│ │ 7 8 9 10 11 12 13 │ │
│ │ 14 15 16 17 18 19 20 │ │
│ │ ░ ░ █ █ ░ █ █ │ │
│ │ █ █ ░ █ █ ░ ░ │ │
│ │ ░ █ █ █ █ █ █ │ │
│ │ │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ Trends │
│ ┌───────────────────────────────────────────────┐ │
│ │ 📈 Workouts trending up! │ │
│ │ +23% vs last month │ │
│ │ │ │
│ │ [WEEKLY CHART] │ │
│ │ │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ Burn Bar Position │
│ ┌───────────────────────────────────────────────┐ │
│ │ Your average: 45 cal/workout │ │
│ │ ████████████░░░░ 68th percentile │ │
│ └───────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────┘
```
---
### 7. Browse Tab
```
┌─────────────────────────────────────────────────────┐
│ │
│ Browse │
│ │
│ Filters [Edit] │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ All ▼ │ │ 4 min │ │ 8 min │ │ Begin │ │
│ └────────┘ └────────┘ └────────┘ └────────┘ │
│ │
│ Trainers │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ 👩 │ │ 👨 │ │ 👩 │ │ 👨 │ │
│ │ Emma │ │ Jake │ │ Mia │ │ Alex │ │
│ │ 12 wk │ │ 8 wk │ │ 10 wk │ │ 6 wk │ │
│ └────────┘ └────────┘ └────────┘ └────────┘ │
│ │
│ Duration │
│ ┌─────────────────────────────────────────────┐ │
│ │ ○ 4 min (Classic Tabata) 20 │ │
│ │ ○ 8 min (Double Tabata) 15 │ │
│ │ ○ 12 min (Triple Tabata) 10 │ │
│ │ ○ 20 min (Tabata Marathon) 5 │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ Focus Area │
│ ┌─────────────────────────────────────────────┐ │
│ │ ○ Full Body 20 │ │
│ │ ○ Upper Body 8 │ │
│ │ ○ Lower Body 8 │ │
│ │ ○ Core 8 │ │
│ │ ○ Cardio 6 │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ Music Vibe │
│ ┌─────────────────────────────────────────────┐ │
│ │ ○ Electronic Energy 18 │ │
│ │ ○ Hip-Hop Beats 12 │ │
│ │ ○ Rock Power 10 │ │
│ │ ○ Chill Focus 10 │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ Collections │
│ ┌─────────────────────────────────────────────┐ │
│ │ 🌅 Morning Energizer • 5 workouts │ │
│ └─────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────┐ │
│ │ 💪 No Equipment • 15 workouts │ │
│ └─────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────┐ │
│ │ 🔥 7-Day Challenge • 7 workouts │ │
│ └─────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────┘
```
---
### 8. Profile Tab
```
┌─────────────────────────────────────────────────────┐
│ │
│ Profile │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ │ │
│ │ 👤 Alex Martin │ │
│ │ Member since Jan 2026 │ │
│ │ ✨ Premium │ │
│ │ │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ Weekly Goal │
│ ┌───────────────────────────────────────────────┐ │
│ │ 5 workouts per week │ │
│ │ ████████████████░░░░ 4/5 this week │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ Achievements │
│ ┌───────────────────────────────────────────────┐ │
│ │ 🏆 7-Day Streak 🥵 First Sweat │ │
│ │ 💯 100 Workouts 🌅 Early Bird │ │
│ │ 🔥 500 Calories ⚡ Speed Demon │ │
│ │ │ │
│ │ [See All Achievements →] │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ Settings │
│ ┌───────────────────────────────────────────────┐ │
│ │ Notifications [→] │ │
│ │ Apple Watch [→] │ │
│ │ Music Preferences [→] │ │
│ │ Workout Preferences [→] │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ Account │
│ ┌───────────────────────────────────────────────┐ │
│ │ Subscription [→] │ │
│ │ Privacy & Security [→] │ │
│ │ Help & Support [→] │ │
│ │ Sign Out │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ TabataFit v1.0.0 │
│ Made with ❤️ for HIIT lovers │
│ │
└─────────────────────────────────────────────────────┘
```
---
## Animation Specifications
### Screen Transitions
- **Push/Pop**: 300ms ease-out
- **Modal**: Slide up from bottom, 350ms
### Micro-interactions
- **Button press**: Scale to 0.96, 100ms
- **Card tap**: Scale to 0.98, 150ms
- **Toggle**: 200ms spring animation
### Timer Animations
- **Countdown**: Number scales up/down each second
- **Progress bar**: Smooth width animation
- **Phase change**: Color crossfade 300ms
### Celebration
- **Confetti**: Lottie animation on workout complete
- **Rings**: Animated fill when stats update
- **Streak badge**: Pulse animation
---
## Accessibility
- **Dynamic Type**: Support up to 200% text scaling
- **VoiceOver**: Full screen reader support
- **Reduce Motion**: Disable animations when requested
- **High Contrast**: Alternative color scheme option
---
*Document created: February 18, 2026*
*Version: 2.0*
*Design System: Apple Fitness+ Inspired*

View File

@@ -1,545 +0,0 @@
# TabataFit — Product Requirements Document v2.0
> Apple Fitness+ for Tabata — The Premium HIIT Experience
---
## Vision Statement
**TabataFit est l'Apple Fitness+ du Tabata.** Une expérience premium, visuellement stunante, guidée par des coachs, qui transforme 4 minutes d'exercice en une expérience de fitness immersive.
*"Workouts that work. Beautifully."*
---
## Positionnement
| Aspect | Apple Fitness+ | TabataFit |
|--------|---------------|-----------|
| **Focus** | Multi-activité (Yoga, HIIT, Strength, etc.) | Spécialiste Tabata/HIIT |
| **Durée** | 5-45 min | 4-20 min (format Tabata) |
| **Différenciateur** | Intégration Apple Watch | Timer intelligent + Coaching audio |
| **Cible** | Grand public fitness | Athlètes HIIT, busy professionals |
| **Vibe** | Studio californien | Énergie explosive, motivational |
---
## Core Philosophy — Apple Fitness+ Principles
1. **Content is King** — Vidéos HD, coachs charismatiques, production Netflix-quality
2. **Inclusive** — Tous niveaux, modifications montrées
3. **Personalized** — Recommandations basées sur l'historique
4. **Immersive** — Music sync, Burn Bar, stats temps réel
5. **Beautiful** — Design épuré, animations fluides, dark theme élégant
---
## Architecture Produit
### Tab Bar (5 onglets — comme Apple Fitness+)
```
┌─────────────────────────────────────────────────────────────┐
│ │
│ 🏠 Home 🔥 Workouts 📊 Activity 🔍 Browse 👤 Profile │
│ │
└─────────────────────────────────────────────────────────────┘
```
### 1. Home Tab — "For You"
**Inspiration**: Apple Fitness+ Home — grande bannière, collections, recommandations
```
┌─────────────────────────────────────────┐
│ ☀️ Bonjour Alex │
│ │
│ ┌───────────────────────────────────┐ │
│ │ 🎬 FEATURED WORKOUT │ │
│ │ ─────────────────────── │ │
│ │ Full Body Burn │ │
│ │ 4 min • Beginner • Emma │ │
│ │ │ │
│ │ [▶️ START NOW] │ │
│ └───────────────────────────────────┘ │
│ │
│ Continue Watching │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ 65%│ │ 30%│ │ 10%│ │
│ └─────┘ └─────┘ └─────┘ │
│ │
│ Popular This Week │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ └─────┘ └─────┘ └─────┘ └─────┘ │
│ │
│ Collections │
│ 🌅 Morning Energizer │
│ 💪 No Equipment Needed │
│ 🔥 7-Day Challenge │
│ │
└─────────────────────────────────────────┘
```
**Elements clés:**
- Hero banner avec vidéo preview en boucle
- "Continue Watching" — workouts non terminés
- "Popular This Week" — trending workouts
- Collections thématiques
- Coach du moment
### 2. Workouts Tab — Parcourir par type
**Inspiration**: Apple Fitness+ workout browser — categories visuelles
```
┌─────────────────────────────────────────┐
│ WORKOUTS 🔍 │
│ │
│ ┌─────────────────────────────────┐ │
│ │ 🔥 QUICK BURN │ │
│ │ 4 min • All levels │ │
│ │ 12 workouts │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ 💪 STRENGTH TABATA │ │
│ │ 8 min • Intermediate │ │
│ │ 8 workouts │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ 🏃 CARDIO BLAST │ │
│ │ 4-12 min • All levels │ │
│ │ 15 workouts │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ 🧘 CORE & FLEXIBILITY │ │
│ │ 4 min • Beginner friendly │ │
│ │ 6 workouts │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ ⚡ HIIT EXTREME │ │
│ │ 12-20 min • Advanced │ │
│ │ 10 workouts │ │
│ └─────────────────────────────────┘ │
│ │
└─────────────────────────────────────────┘
```
**Categories:**
1. **Quick Burn** — 4 min, perfect for beginners
2. **Strength Tabata** — Resistance exercises
3. **Cardio Blast** — Pure cardio, no equipment
4. **Core & Flexibility** — Abs, stretching
5. **HIIT Extreme** — Advanced, longer sessions
### 3. Activity Tab — Stats & Progress
**Inspiration**: Apple Fitness+ Activity rings + trends
```
┌─────────────────────────────────────────┐
│ ACTIVITY │
│ │
│ ┌─────────────────────────────────┐ │
│ │ 🔥 STREAK │ │
│ │ ───────── │ │
│ │ 7 │ │
│ │ DAYS │ │
│ │ │ │
│ │ ○ ○ ○ ○ ○ ○ ○ ● ○ ○ │ │
│ │ M T W T F S S M T │ │
│ └─────────────────────────────────┘ │
│ │
│ This Week │
│ ┌───────┐ ┌───────┐ ┌───────┐ │
│ │ 5 │ │ 156 │ │ 32 │ │
│ │Workout│ │ Calories│ │ Minutes│ │
│ └───────┘ └───────┘ └───────┘ │
│ │
│ Trends │
│ ┌─────────────────────────────────┐ │
│ │ 📈 Workouts are trending up! │ │
│ │ +23% vs last month │ │
│ └─────────────────────────────────┘ │
│ │
│ Burn Bar Position │
│ ┌─────────────────────────────────┐ │
│ │ Your avg: 45 cal/workout │ │
│ │ ████████░░░░ 68th percentile │ │
│ └─────────────────────────────────┘ │
│ │
│ Monthly Summary │
│ [ Calendar view with heat map ] │
│ │
└─────────────────────────────────────────┘
```
**Features:**
- Streak counter avec calendrier visuel
- Stats hebdomadaires (workouts, calories, minutes)
- Trends ("You're on fire! 🔥")
- Burn Bar — comparaison avec autres utilisateurs
- Calendar heat map
### 4. Browse Tab — Tout le contenu
**Inspiration**: Apple Fitness+ Browse — filtres, trainers, music
```
┌─────────────────────────────────────────┐
│ BROWSE │
│ │
│ Filters [Edit]│
│ [All ▼] [4 min] [8 min] [Beginner] │
│ │
│ By Trainer │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ 👩 │ │ 👨 │ │ 👩 │ │ 👨 │ │
│ │Emma │ │Jake │ │Mia │ │Alex │ │
│ └─────┘ └─────┘ └─────┘ └─────┘ │
│ │
│ By Duration │
│ ○ 4 min (Classic Tabata) │
│ ○ 8 min (Double Tabata) │
│ ○ 12 min (Triple Tabata) │
│ ○ 20 min (Tabata Marathon) │
│ │
│ By Focus Area │
│ ○ Full Body │
│ ○ Upper Body │
│ ○ Lower Body │
│ ○ Core │
│ ○ Cardio │
│ │
│ Music Vibe │
│ ○ Electronic Energy │
│ ○ Hip-Hop Beats │
│ ○ Rock Power │
│ ○ Chill Focus │
│ │
└─────────────────────────────────────────┘
```
### 5. Profile Tab — Settings & Account
**Inspiration**: Apple Fitness+ Profile — minimal, clean
```
┌─────────────────────────────────────────┐
│ PROFILE │
│ │
│ ┌─────────────────────────────────┐ │
│ │ 👤 Alex Martin │ │
│ │ Member since Jan 2026 │ │
│ │ Premium ✨ │ │
│ └─────────────────────────────────┘ │
│ │
│ Goals │
│ ┌─────────────────────────────────┐ │
│ │ Weekly Goal: 5 workouts │ │
│ │ ████████░░ 4/5 this week │ │
│ └─────────────────────────────────┘ │
│ │
│ Achievements │
│ 🏆 7-Day Streak │
│ 🥵 First Sweat │
│ 💯 100 Workouts │
│ [See all →] │
│ │
│ Settings │
│ • Notifications │
│ • Apple Watch │
│ • Music Preferences │
│ • Account │
│ • Subscription │
│ │
└─────────────────────────────────────────┘
```
---
## The Workout Experience — Cœur du Produit
### Pre-Workout Screen
**Inspiration**: Apple Fitness+ workout preview — trailer, details, start
```
┌─────────────────────────────────────────┐
│ ← │
│ │
│ ┌───────────────────────────────────┐ │
│ │ │ │
│ │ [VIDEO PREVIEW LOOP] │ │
│ │ Coach Emma demonstrating │ │
│ │ Jump Squats │ │
│ │ │ │
│ └───────────────────────────────────┘ │
│ │
│ FULL BODY BURN │
│ ───────────────────────────────────── │
│ │
│ 👩 Emma • 💪 Intermediate • ⏱️ 4 min │
│ │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ │
│ What You'll Need │
│ ○ No equipment │
│ ○ Mat recommended │
│ │
│ Exercises Preview │
│ 1. Jump Squats (20s work) │
│ 2. Mountain Climbers (20s work) │
│ 3. Burpees (20s work) │
│ 4. High Knees (20s work) │
× 2 rounds │
│ │
│ Music │
│ 🎵 Electronic Energy playlist │
│ │
│ ┌───────────────────────────────────┐ │
│ │ ▶️ START WORKOUT │ │
│ └───────────────────────────────────┘ │
│ │
└─────────────────────────────────────────┘
```
### Active Workout Screen — Apple Fitness+ Style
**Inspiration**: Apple Fitness+ player — video dominant, stats overlay
```
┌─────────────────────────────────────────┐
│ │
│ ┌───────────────────────────────────┐ │
│ │ │ │
│ │ │ │
│ │ [FULL SCREEN VIDEO] │ │
│ │ │ │
│ │ Coach doing exercise │ │
│ │ in perfect form │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ 🔥 WORK • 00:14 │ │ │
│ │ │ Round 3 of 8 │ │ │
│ │ │ ████████████░░░░ 65% │ │ │
│ │ └─────────────────────────────┘ │ │
│ │ │ │
│ │ │ │
│ └───────────────────────────────────┘ │
│ │
│ JUMP SQUATS │
│ ───────────────────────────────────── │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 14 │ │ 52 │ │ 85% │ │
│ │ cal │ │ bpm │ │ effort │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ Burn Bar: ████████░░░░ 72% │
│ │
└─────────────────────────────────────────┘
```
**Key Features:**
- Video full screen avec coach
- Timer overlay (phase + countdown)
- Round indicator
- Progress bar
- Stats temps réel (calories, bpm si Apple Watch)
- Burn Bar
### During Rest Phase
```
┌─────────────────────────────────────────┐
│ │
│ ┌───────────────────────────────────┐ │
│ │ │ │
│ │ [COACH IN REST POSE] │ │
│ │ Stretching / breathing │ │
│ │ │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ 💙 REST • 00:08 │ │ │
│ │ │ Next: Mountain Climbers │ │ │
│ │ │ ░░░░░░░░░░░░░░░░░░ 40% │ │ │
│ │ └─────────────────────────────┘ │ │
│ │ │ │
│ │ "Shake it out, you're │ │
│ │ doing great!" │ │
│ │ │ │
│ └───────────────────────────────────┘ │
│ │
│ Up Next: Mountain Climbers │
│ [GIF preview of next exercise] │
│ │
└─────────────────────────────────────────┘
```
### Workout Complete — Celebration
**Inspiration**: Apple Fitness+ celebration screen
```
┌─────────────────────────────────────────┐
│ │
│ 🎉 WORKOUT COMPLETE! │
│ │
│ ┌───────────────────────────────────┐ │
│ │ │ │
│ │ [ANIMATED RINGS] │ │
│ │ 🔥 🔥 🔥 │ │
│ │ │ │
│ └───────────────────────────────────┘ │
│ │
│ YOUR STATS │
│ ───────────────────────────────────── │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 52 │ │ 4 │ │ 100% │ │
│ │ CALORIES│ │ MINUTES │ │ COMPLETE│ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ Burn Bar │
│ ████████████░░░ You beat 73%! │
│ │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ │
│ 🔥 7 Day Streak! Keep it going! │
│ │
│ ┌───────────────────────────────────┐ │
│ │ SHARE YOUR WORKOUT │ │
│ └───────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────┐ │
│ │ ← BACK TO HOME │ │
│ └───────────────────────────────────┘ │
│ │
└─────────────────────────────────────────┘
```
---
## Content Strategy — 50+ Workouts au Launch
### Par Durée
| Duration | Format | Rounds | Count |
|----------|--------|--------|-------|
| 4 min | Classic Tabata | 8 rounds | 20 workouts |
| 8 min | Double Tabata | 16 rounds | 15 workouts |
| 12 min | Triple Tabata | 24 rounds | 10 workouts |
| 20 min | Tabata Marathon | 40 rounds | 5 workouts |
### Par Focus
- **Full Body** — 20 workouts
- **Upper Body** — 8 workouts
- **Lower Body** — 8 workouts
- **Core** — 8 workouts
- **Cardio Only** — 6 workouts
### Par Niveau
- **Beginner** — 15 workouts
- **Intermediate** — 20 workouts
- **Advanced** — 15 workouts
### Trainers (5 au launch)
1. **Emma** — Energy queen, beginner-friendly
2. **Jake** — Strength focus, motivating
3. **Mia** — Form perfectionist, technical
4. **Alex** — Cardio beast, intense
5. **Sofia** — Chill but effective, recovery
---
## Technical Requirements
### Video Pipeline
- HLS streaming (adaptive bitrate)
- 1080p minimum, 4K for featured
- Offline download for Premium
- Preload next exercise during rest
### Audio
- Multiple music tracks (by vibe)
- Coach voice-over (can be muted)
- Sound effects (beeps, transitions)
- Haptic feedback sync
### Apple Watch Integration
- Heart rate display
- Calories calculation
- Activity rings update
- Now Playing controls
### Offline Support
- Download workouts for offline
- Sync when back online
- Local stats caching
---
## Monetization
### Free Tier
- 3 workouts free forever
- Basic stats
- Ads between workouts
### Premium ($6.99/mo or $49.99/yr)
- Unlimited workouts
- All trainers
- Offline downloads
- Advanced stats & trends
- Apple Watch integration
- No ads
- Family Sharing (up to 5)
---
## Success Metrics
| Metric | Target (Month 3) |
|--------|------------------|
| DAU | 10,000 |
| Workout completion rate | 75% |
| 7-day retention | 40% |
| Premium conversion | 8% |
| Average workouts/user/week | 3.5 |
---
## Roadmap
### Phase 1 — MVP (Weeks 1-4)
- [ ] Home + Workouts tabs
- [ ] 20 workouts (4 min only)
- [ ] 2 trainers
- [ ] Basic timer + video player
### Phase 2 — Core (Weeks 5-8)
- [ ] Activity tab with stats
- [ ] 30 workouts total
- [ ] 4 trainers
- [ ] Apple Watch integration
### Phase 3 — Premium (Weeks 9-12)
- [ ] Browse + Profile tabs
- [ ] 50+ workouts
- [ ] 5 trainers
- [ ] Offline downloads
- [ ] Burn Bar
- [ ] Subscription system
---
*Document created: February 18, 2026*
*Version: 2.0*
*Status: Ready for Design Phase*

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -0,0 +1,86 @@
import { GoogleGenAI } from '@google/genai'
import { NextResponse } from 'next/server'
import { supabase } from '@/lib/supabase'
export async function POST(request: Request) {
try {
const { prompt, trainerId, filename } = await request.json()
const apiKey = process.env.GEMINI_API_KEY
if (!apiKey) {
return NextResponse.json(
{ error: 'GEMINI_API_KEY not configured' },
{ status: 500 }
)
}
const ai = new GoogleGenAI({ apiKey })
const config = {
imageConfig: {
aspectRatio: '1:1',
imageSize: '1K',
},
responseModalities: ['IMAGE', 'TEXT'] as string[],
}
const response = await ai.models.generateContentStream({
model: 'gemini-3.1-flash-image-preview',
config,
contents: [{ role: 'user', parts: [{ text: prompt }] }],
})
const images: { buffer: Buffer; mimeType: string }[] = []
for await (const chunk of response) {
const parts = chunk.candidates?.[0]?.content?.parts
if (!parts) continue
for (const part of parts) {
if (part.inlineData) {
images.push({
buffer: Buffer.from(part.inlineData.data || '', 'base64'),
mimeType: part.inlineData.mimeType || 'image/png',
})
}
}
}
if (images.length === 0) {
return NextResponse.json(
{ error: 'No images generated' },
{ status: 500 }
)
}
// Save first image to storage
const image = images[0]
const path = `${trainerId}/${filename}`
const { error } = await supabase.storage
.from('trainer-avatars')
.upload(path, image.buffer, {
contentType: image.mimeType,
upsert: true,
})
if (error) {
return NextResponse.json(
{ error: `Failed to upload avatar: ${error.message}` },
{ status: 500 }
)
}
const { data: { publicUrl } } = supabase.storage
.from('trainer-avatars')
.getPublicUrl(path)
return NextResponse.json({ url: publicUrl })
} catch (error) {
console.error('Avatar generation failed:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Generation failed' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,297 @@
import { GoogleGenAI, VideoGenerationReferenceType } from '@google/genai'
import { NextResponse } from 'next/server'
import { supabase } from '@/lib/supabase'
interface Exercise {
name: string
duration: number
}
interface GeneratedVideo {
exerciseName: string
videoType: 'exercise' | 'rest'
videoUrl: string
videoPath: string
durationSeconds: number
}
async function pollOperation(ai: GoogleGenAI, operation: any): Promise<any> {
while (!operation.done) {
await new Promise(resolve => setTimeout(resolve, 10000))
operation = await ai.operations.getVideosOperation({ operation })
}
return operation
}
async function uploadVideo(
bucket: string,
path: string,
buffer: Buffer
): Promise<string> {
const { error } = await supabase.storage
.from(bucket)
.upload(path, buffer, {
contentType: 'video/mp4',
upsert: true,
})
if (error) {
throw new Error(`Failed to upload video: ${error.message}`)
}
const { data: { publicUrl } } = supabase.storage
.from(bucket)
.getPublicUrl(path)
return publicUrl
}
async function getMimeTypeFromUrl(url: string): Promise<string> {
try {
const urlPath = new URL(url).pathname
const extension = urlPath.split('.').pop()?.toLowerCase()
switch (extension) {
case 'png':
return 'image/png'
case 'jpg':
case 'jpeg':
return 'image/jpeg'
case 'webp':
return 'image/webp'
case 'gif':
return 'image/gif'
default:
return 'image/png'
}
} catch {
return 'image/png'
}
}
async function generateVideoWithReference(
ai: GoogleGenAI,
apiKey: string,
prompt: string,
avatarUrl: string,
durationSeconds: number = 8
): Promise<{ uri: string; buffer: Buffer } | null> {
// Fetch avatar image from URL and convert to base64
const avatarResponse = await fetch(avatarUrl)
const avatarBuffer = await avatarResponse.arrayBuffer()
const avatarBase64 = Buffer.from(avatarBuffer).toString('base64')
const mimeType = await getMimeTypeFromUrl(avatarUrl)
const operation = await ai.models.generateVideos({
model: 'veo-3.1-fast-generate-preview',
prompt,
config: {
numberOfVideos: 1,
aspectRatio: '16:9',
resolution: '1080p',
durationSeconds,
referenceImages: [{
image: {
imageBytes: avatarBase64,
mimeType,
},
referenceType: 'character' as any,
}],
},
})
const completedOperation = await pollOperation(ai, operation)
const videoUri = completedOperation.response?.generatedVideos?.[0]?.video?.uri
if (!videoUri) {
return null
}
const response = await fetch(`${videoUri}&key=${apiKey}`)
const arrayBuffer = await response.arrayBuffer()
const videoBuffer = Buffer.from(arrayBuffer)
return { uri: videoUri, buffer: videoBuffer }
}
async function extendVideo(
ai: GoogleGenAI,
apiKey: string,
videoUri: string,
prompt: string,
durationSeconds: number = 8
): Promise<{ uri: string; buffer: Buffer } | null> {
const operation = await ai.models.generateVideos({
model: 'veo-3.1-fast-generate-preview',
prompt,
video: videoUri as any,
config: {
numberOfVideos: 1,
durationSeconds,
},
})
const completedOperation = await pollOperation(ai, operation)
const newVideoUri = completedOperation.response?.generatedVideos?.[0]?.video?.uri
if (!newVideoUri) {
return null
}
const response = await fetch(`${newVideoUri}&key=${apiKey}`)
const arrayBuffer = await response.arrayBuffer()
const videoBuffer = Buffer.from(arrayBuffer)
return { uri: newVideoUri, buffer: videoBuffer }
}
export async function POST(request: Request) {
try {
const { workoutId, trainerId, trainerAvatarUrl, exercises } = await request.json()
if (!trainerAvatarUrl) {
return NextResponse.json(
{ error: 'Trainer avatar URL is required' },
{ status: 400 }
)
}
const apiKey = process.env.GEMINI_API_KEY
if (!apiKey) {
return NextResponse.json(
{ error: 'GEMINI_API_KEY not configured' },
{ status: 500 }
)
}
const ai = new GoogleGenAI({ apiKey })
const generatedVideos: GeneratedVideo[] = []
for (const exercise of exercises) {
const exerciseName = exercise.name
console.log(`Processing exercise: ${exerciseName}`)
const safeName = exerciseName.toLowerCase().replace(/[^a-z0-9]/g, '_')
// 1. Generate 8s exercise video with avatar reference
console.log(`Generating base exercise video for: ${exerciseName}`)
const exercisePrompt = `A fitness trainer demonstrating ${exerciseName} exercise with correct form and technique. The person should be energetic and perform the exercise properly.`
let exerciseVideo = await generateVideoWithReference(
ai,
apiKey,
exercisePrompt,
trainerAvatarUrl,
8
)
if (!exerciseVideo) {
console.error(`Failed to generate base exercise video for: ${exerciseName}`)
continue
}
// 2. Extend exercise video to 22s (8s + 7s + 7s)
console.log(`Extending exercise video for: ${exerciseName}`)
const extendPrompt1 = `Continue the ${exerciseName} exercise motion seamlessly`
let extended1 = await extendVideo(ai, apiKey, exerciseVideo.uri, extendPrompt1, 8)
if (extended1) {
const extendPrompt2 = `Continue the ${exerciseName} exercise motion seamlessly`
const extended2 = await extendVideo(ai, apiKey, extended1.uri, extendPrompt2, 8)
if (extended2) {
exerciseVideo = extended2
}
}
const exercisePath = `${trainerId}/${workoutId}/${safeName}.mp4`
const exerciseUrl = await uploadVideo('trainer-videos', exercisePath, exerciseVideo.buffer)
generatedVideos.push({
exerciseName,
videoType: 'exercise',
videoUrl: exerciseUrl,
videoPath: exercisePath,
durationSeconds: 22,
})
console.log(`Exercise video uploaded: ${exerciseUrl}`)
// 3. Generate 8s rest video with avatar reference
console.log(`Generating rest video for: ${exerciseName}`)
const restPrompt = `A fitness trainer resting, standing still, breathing calmly with arms at sides. The person looks relaxed.`
const restVideo = await generateVideoWithReference(
ai,
apiKey,
restPrompt,
trainerAvatarUrl,
8
)
if (restVideo) {
const restPath = `${trainerId}/${workoutId}/${safeName}_rest.mp4`
const restUrl = await uploadVideo('trainer-videos', restPath, restVideo.buffer)
generatedVideos.push({
exerciseName,
videoType: 'rest',
videoUrl: restUrl,
videoPath: restPath,
durationSeconds: 8,
})
console.log(`Rest video uploaded: ${restUrl}`)
} else {
console.error(`Failed to generate rest video for: ${exerciseName}`)
}
}
if (generatedVideos.length === 0) {
return NextResponse.json(
{ error: 'No videos were generated' },
{ status: 500 }
)
}
// Save all videos to exercise_videos table
for (const video of generatedVideos) {
await (supabase.from('exercise_videos') as any)
.insert({
workout_id: workoutId,
trainer_id: trainerId,
exercise_name: video.exerciseName,
video_url: video.videoUrl,
video_path: video.videoPath,
video_type: video.videoType,
duration_seconds: video.durationSeconds,
})
}
// Update workout with primary video_url (first exercise video)
const primaryVideo = generatedVideos.find(v => v.videoType === 'exercise')
if (primaryVideo) {
await (supabase.from('workouts') as any)
.update({ video_url: primaryVideo.videoUrl })
.eq('id', workoutId)
}
// Mark workout as generated in program_workouts if applicable
await (supabase.from('program_workouts') as any)
.update({ video_generated: true })
.eq('workout_id', workoutId)
return NextResponse.json({
workoutId,
videos: generatedVideos,
totalVideos: generatedVideos.length,
})
} catch (error) {
console.error('Video generation failed:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Generation failed' },
{ status: 500 }
)
}
}

View File

@@ -3,10 +3,9 @@
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Feb 20, 2026
### Apr 16, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5391 | 10:20 PM | 🟣 | RevenueCat subscription system fully integrated and tested | ~656 |
| #5384 | 9:28 PM | 🟣 | Implemented RevenueCat subscription system | ~190 |
| #6319 | 9:38 PM | 🔵 | Discovered admin UI program management pages created | ~267 |
</claude-mem-context>

View File

@@ -0,0 +1,11 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Apr 16, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6319 | 9:38 PM | 🔵 | Discovered admin UI program management pages created | ~267 |
</claude-mem-context>

View File

@@ -0,0 +1,11 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Apr 16, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6319 | 9:38 PM | 🔵 | Discovered admin UI program management pages created | ~267 |
</claude-mem-context>

View File

@@ -0,0 +1,94 @@
import { Metadata } from "next"
import Link from "next/link"
import { notFound } from "next/navigation"
import { ArrowLeft } from "lucide-react"
import { Button } from "@/components/ui/button"
import ProgramForm from "@/components/program-form"
import { supabase } from "@/lib/supabase"
interface EditProgramPageProps {
params: Promise<{
id: string
}>
}
async function getProgram(id: string) {
const { data, error } = await (supabase.from("workout_programs") as any)
.select(`
*,
program_tabatas (*),
workout_warmup_exercises (*),
workout_stretch_exercises (*)
`)
.eq("id", id)
.single()
if (error || !data) {
return null
}
// Sort tabatas by position
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
}
export async function generateMetadata({ params }: EditProgramPageProps): Promise<Metadata> {
const resolvedParams = await params
const program = await getProgram(resolvedParams.id)
if (!program) {
return {
title: "Program Not Found | TabataFit Admin",
}
}
return {
title: `Edit ${program.title} | TabataFit Admin`,
}
}
export default async function EditProgramPage({ params }: EditProgramPageProps) {
const resolvedParams = await params
const program = await getProgram(resolvedParams.id)
if (!program) {
notFound()
}
return (
<div className="p-8 max-w-4xl mx-auto">
<div className="mb-8">
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
<Link href={`/programs/${program.id}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Program
</Link>
</Button>
<h1 className="text-3xl font-bold text-white mb-2">Edit Program</h1>
<p className="text-neutral-400">
Update the details for &quot;{program.title}&quot;
</p>
</div>
<div className="rounded-lg border border-neutral-800 bg-neutral-900 p-6">
<ProgramForm initialData={program} mode="edit" />
</div>
</div>
)
}

View File

@@ -0,0 +1,292 @@
import { Metadata } from "next"
import Link from "next/link"
import { notFound } from "next/navigation"
import { ArrowLeft, Edit, Trash2, Clock, Flame, Dumbbell, Zap, Timer, Target } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { supabase } from "@/lib/supabase"
interface ProgramDetailPageProps {
params: Promise<{
id: string
}>
}
const BODY_ZONE_COLORS: Record<string, string> = {
"upper-body": "bg-green-500/20 text-green-500",
"lower-body": "bg-purple-500/20 text-purple-500",
"full-body": "bg-orange-500/20 text-orange-500",
}
const LEVEL_COLORS: Record<string, string> = {
"Beginner": "bg-emerald-500/20 text-emerald-500",
"Intermediate": "bg-yellow-500/20 text-yellow-500",
"Advanced": "bg-red-500/20 text-red-500",
}
async function getProgram(id: string) {
const { data, error } = await (supabase.from("workout_programs") as any)
.select(`
*,
program_tabatas (*)
`)
.eq("id", id)
.single()
if (error || !data) {
return null
}
// Sort tabatas by position
if (data.program_tabatas) {
data.program_tabatas.sort((a: any, b: any) => a.position - b.position)
}
return data
}
export async function generateMetadata({ params }: ProgramDetailPageProps): Promise<Metadata> {
const resolvedParams = await params
const program = await getProgram(resolvedParams.id)
if (!program) {
return {
title: "Program Not Found | TabataFit Admin",
}
}
return {
title: `${program.title} | TabataFit Admin`,
}
}
export default async function ProgramDetailPage({ params }: ProgramDetailPageProps) {
const resolvedParams = await params
const program = await getProgram(resolvedParams.id)
if (!program) {
notFound()
}
const tabatas = program.program_tabatas || []
return (
<div className="p-8 max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-start justify-between mb-8">
<div>
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
<Link href="/programs">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Programs
</Link>
</Button>
<div className="flex items-center gap-3 mb-2">
{program.accent_color && (
<div
className="h-4 w-4 rounded-full flex-shrink-0"
style={{ backgroundColor: program.accent_color }}
/>
)}
<h1 className="text-3xl font-bold text-white">{program.title}</h1>
<Badge className={program.is_free ? "bg-emerald-500/20 text-emerald-500" : "bg-amber-500/20 text-amber-500"}>
{program.is_free ? "Free" : "Premium"}
</Badge>
</div>
<div className="flex items-center gap-2 text-neutral-400">
<Badge className={BODY_ZONE_COLORS[program.body_zone] || "bg-neutral-500/20 text-neutral-500"}>
{program.body_zone}
</Badge>
<span className="text-neutral-600">|</span>
<Badge className={LEVEL_COLORS[program.level] || "bg-neutral-500/20 text-neutral-500"}>
{program.level}
</Badge>
</div>
{program.description && (
<p className="text-neutral-400 mt-3 max-w-2xl">{program.description}</p>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" asChild className="border-neutral-700">
<Link href={`/programs/${program.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<Card className="bg-neutral-900 border-neutral-800">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-neutral-400 mb-2">
<Clock className="h-4 w-4" />
<span className="text-sm">Duration</span>
</div>
<p className="text-2xl font-bold text-white">{program.estimated_duration} min</p>
<p className="text-xs text-neutral-500">estimated total</p>
</CardContent>
</Card>
<Card className="bg-neutral-900 border-neutral-800">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-neutral-400 mb-2">
<Flame className="h-4 w-4" />
<span className="text-sm">Calories</span>
</div>
<p className="text-2xl font-bold text-white">{program.estimated_calories}</p>
<p className="text-xs text-neutral-500">estimated burn</p>
</CardContent>
</Card>
<Card className="bg-neutral-900 border-neutral-800">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-neutral-400 mb-2">
<Dumbbell className="h-4 w-4" />
<span className="text-sm">Tabatas</span>
</div>
<p className="text-2xl font-bold text-white">{tabatas.length}</p>
<p className="text-xs text-neutral-500">exercise pairs</p>
</CardContent>
</Card>
<Card className="bg-neutral-900 border-neutral-800">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-neutral-400 mb-2">
<Target className="h-4 w-4" />
<span className="text-sm">Sort Order</span>
</div>
<p className="text-2xl font-bold text-white">{program.sort_order}</p>
<p className="text-xs text-neutral-500">display position</p>
</CardContent>
</Card>
</div>
{/* Tabatas */}
<div className="space-y-6">
{tabatas.map((tabata: any) => (
<Card key={tabata.id} className="bg-neutral-900 border-neutral-800">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-orange-500/20 text-orange-500 font-bold text-sm">
{tabata.position}
</div>
<CardTitle className="text-white">Tabata {tabata.position}</CardTitle>
</div>
<div className="flex items-center gap-4 text-sm text-neutral-400">
<span className="flex items-center gap-1">
<Timer className="h-3.5 w-3.5" />
{tabata.rounds} rounds
</span>
<span className="flex items-center gap-1">
<Zap className="h-3.5 w-3.5" />
{tabata.work_time}s work
</span>
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{tabata.rest_time}s rest
</span>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
{/* Exercise 1 */}
<div className="rounded-lg border border-neutral-800 bg-neutral-950 p-4 space-y-3">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="bg-orange-500/20 text-orange-500">
Exercise 1
</Badge>
</div>
<div>
<p className="text-white font-medium">{tabata.exercise_1_name}</p>
{tabata.exercise_1_name_en && (
<p className="text-sm text-neutral-500">{tabata.exercise_1_name_en}</p>
)}
</div>
{tabata.exercise_1_tip && (
<div>
<p className="text-xs text-neutral-500 mb-0.5">Tip</p>
<p className="text-sm text-neutral-300">{tabata.exercise_1_tip}</p>
{tabata.exercise_1_tip_en && (
<p className="text-xs text-neutral-500">{tabata.exercise_1_tip_en}</p>
)}
</div>
)}
{tabata.exercise_1_modification && (
<div>
<p className="text-xs text-neutral-500 mb-0.5">Modification</p>
<p className="text-sm text-neutral-300">{tabata.exercise_1_modification}</p>
{tabata.exercise_1_modification_en && (
<p className="text-xs text-neutral-500">{tabata.exercise_1_modification_en}</p>
)}
</div>
)}
{tabata.exercise_1_progression && (
<div>
<p className="text-xs text-neutral-500 mb-0.5">Progression</p>
<p className="text-sm text-neutral-300">{tabata.exercise_1_progression}</p>
{tabata.exercise_1_progression_en && (
<p className="text-xs text-neutral-500">{tabata.exercise_1_progression_en}</p>
)}
</div>
)}
</div>
{/* Exercise 2 */}
<div className="rounded-lg border border-neutral-800 bg-neutral-950 p-4 space-y-3">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="bg-blue-500/20 text-blue-500">
Exercise 2
</Badge>
</div>
<div>
<p className="text-white font-medium">{tabata.exercise_2_name}</p>
{tabata.exercise_2_name_en && (
<p className="text-sm text-neutral-500">{tabata.exercise_2_name_en}</p>
)}
</div>
{tabata.exercise_2_tip && (
<div>
<p className="text-xs text-neutral-500 mb-0.5">Tip</p>
<p className="text-sm text-neutral-300">{tabata.exercise_2_tip}</p>
{tabata.exercise_2_tip_en && (
<p className="text-xs text-neutral-500">{tabata.exercise_2_tip_en}</p>
)}
</div>
)}
{tabata.exercise_2_modification && (
<div>
<p className="text-xs text-neutral-500 mb-0.5">Modification</p>
<p className="text-sm text-neutral-300">{tabata.exercise_2_modification}</p>
{tabata.exercise_2_modification_en && (
<p className="text-xs text-neutral-500">{tabata.exercise_2_modification_en}</p>
)}
</div>
)}
{tabata.exercise_2_progression && (
<div>
<p className="text-xs text-neutral-500 mb-0.5">Progression</p>
<p className="text-sm text-neutral-300">{tabata.exercise_2_progression}</p>
{tabata.exercise_2_progression_en && (
<p className="text-xs text-neutral-500">{tabata.exercise_2_progression_en}</p>
)}
</div>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Apr 16, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6319 | 9:38 PM | 🔵 | Discovered admin UI program management pages created | ~267 |
</claude-mem-context>

View File

@@ -0,0 +1,34 @@
import { Metadata } from "next"
import Link from "next/link"
import { ArrowLeft } from "lucide-react"
import { Button } from "@/components/ui/button"
import ProgramForm from "@/components/program-form"
export const metadata: Metadata = {
title: "New Program | TabataFit Admin",
description: "Create a new workout program",
}
export default function NewProgramPage() {
return (
<div className="p-8 max-w-4xl mx-auto">
<div className="mb-8">
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
<Link href="/programs">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Programs
</Link>
</Button>
<h1 className="text-3xl font-bold text-white mb-2">Create New Program</h1>
<p className="text-neutral-400">
Fill in the details below to create a new workout program with 3 tabatas
</p>
</div>
<div className="rounded-lg border border-neutral-800 bg-neutral-900 p-6">
<ProgramForm mode="create" />
</div>
</div>
)
}

View File

@@ -0,0 +1,318 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Select } from "@/components/ui/select";
import { Plus, Trash2, Edit, Loader2, Eye } from "lucide-react";
import { toast } from "sonner";
import type { Database } from "@/lib/supabase";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
type WorkoutProgram = Database["public"]["Tables"]["workout_programs"]["Row"];
const BODY_ZONE_OPTIONS = [
{ value: "all", label: "All Zones" },
{ value: "upper-body", label: "Upper Body" },
{ value: "lower-body", label: "Lower Body" },
{ value: "full-body", label: "Full Body" },
]
const LEVEL_OPTIONS = [
{ value: "all", label: "All Levels" },
{ value: "Beginner", label: "Beginner" },
{ value: "Intermediate", label: "Intermediate" },
{ value: "Advanced", label: "Advanced" },
]
export default function ProgramsPage() {
const router = useRouter();
const [programs, setPrograms] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [programToDelete, setProgramToDelete] = useState<WorkoutProgram | null>(null);
const [filterZone, setFilterZone] = useState("all");
const [filterLevel, setFilterLevel] = useState("all");
useEffect(() => {
fetchPrograms();
}, []);
const fetchPrograms = async () => {
try {
const { data, error } = await (supabase.from("workout_programs") as any)
.select(`
*,
program_tabatas (id)
`)
.order("sort_order", { ascending: true });
if (error) throw error;
setPrograms(data || []);
} catch (error) {
console.error("Failed to fetch programs:", error);
} finally {
setLoading(false);
}
};
const openDeleteDialog = (program: WorkoutProgram) => {
setProgramToDelete(program);
setDeleteDialogOpen(true);
};
const handleDelete = async () => {
if (!programToDelete) return;
setDeletingId(programToDelete.id);
try {
// Delete tabatas first (foreign key constraint)
const { error: tabataError } = await (supabase.from("program_tabatas") as any)
.delete()
.eq("program_id", programToDelete.id);
if (tabataError) throw tabataError;
const { error } = await (supabase.from("workout_programs") as any)
.delete()
.eq("id", programToDelete.id);
if (error) throw error;
setPrograms(programs.filter((p) => p.id !== programToDelete.id));
toast.success("Program deleted", {
description: "The program has been removed successfully."
});
} catch (error) {
console.error("Failed to delete program:", error);
toast.error("Failed to delete program");
} finally {
setDeletingId(null);
setDeleteDialogOpen(false);
setProgramToDelete(null);
}
};
const getBodyZoneColor = (zone: string) => {
const colors: Record<string, string> = {
"upper-body": "bg-green-500/20 text-green-500",
"lower-body": "bg-purple-500/20 text-purple-500",
"full-body": "bg-orange-500/20 text-orange-500",
};
return colors[zone] || "bg-neutral-500/20 text-neutral-500";
};
const getLevelColor = (level: string) => {
const colors: Record<string, string> = {
"Beginner": "bg-emerald-500/20 text-emerald-500",
"Intermediate": "bg-yellow-500/20 text-yellow-500",
"Advanced": "bg-red-500/20 text-red-500",
};
return colors[level] || "bg-neutral-500/20 text-neutral-500";
};
const filteredPrograms = programs.filter((p) => {
if (filterZone !== "all" && p.body_zone !== filterZone) return false;
if (filterLevel !== "all" && p.level !== filterLevel) return false;
return true;
});
return (
<div className="p-8">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Programs</h1>
<p className="text-neutral-400">Manage your workout programs</p>
</div>
<Button className="bg-orange-500 hover:bg-orange-600" asChild>
<Link href="/programs/new">
<Plus className="w-4 h-4 mr-2" />
Add Program
</Link>
</Button>
</div>
{/* Filters */}
<div className="flex gap-4 mb-6">
<div className="w-48">
<Select
value={filterZone}
onValueChange={setFilterZone}
options={BODY_ZONE_OPTIONS}
placeholder="Filter by zone"
/>
</div>
<div className="w-48">
<Select
value={filterLevel}
onValueChange={setFilterLevel}
options={LEVEL_OPTIONS}
placeholder="Filter by level"
/>
</div>
</div>
<Card className="bg-neutral-900 border-neutral-800">
<CardContent className="p-0">
{loading ? (
<div className="p-8 flex justify-center">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
</div>
) : filteredPrograms.length === 0 ? (
<div className="p-8 text-center text-neutral-500">
{programs.length === 0
? "No programs yet. Create your first program to get started."
: "No programs match your filters."}
</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-neutral-800">
<TableHead className="text-neutral-400">Title</TableHead>
<TableHead className="text-neutral-400">Body Zone</TableHead>
<TableHead className="text-neutral-400">Level</TableHead>
<TableHead className="text-neutral-400">Access</TableHead>
<TableHead className="text-neutral-400">Duration</TableHead>
<TableHead className="text-neutral-400">Calories</TableHead>
<TableHead className="text-neutral-400">Tabatas</TableHead>
<TableHead className="text-neutral-400 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPrograms.map((program) => (
<TableRow
key={program.id}
className="border-neutral-800 cursor-pointer hover:bg-neutral-800/50 transition-colors"
onClick={() => router.push(`/programs/${program.id}`)}
>
<TableCell className="text-white font-medium">
<div className="flex items-center gap-2">
{program.accent_color && (
<div
className="h-3 w-3 rounded-full flex-shrink-0"
style={{ backgroundColor: program.accent_color }}
/>
)}
{program.title}
</div>
</TableCell>
<TableCell>
<Badge className={getBodyZoneColor(program.body_zone)}>
{program.body_zone}
</Badge>
</TableCell>
<TableCell>
<Badge className={getLevelColor(program.level)}>
{program.level}
</Badge>
</TableCell>
<TableCell>
<Badge className={program.is_free ? "bg-emerald-500/20 text-emerald-500" : "bg-amber-500/20 text-amber-500"}>
{program.is_free ? "Free" : "Premium"}
</Badge>
</TableCell>
<TableCell className="text-neutral-300">{program.estimated_duration} min</TableCell>
<TableCell className="text-neutral-300">{program.estimated_calories}</TableCell>
<TableCell className="text-neutral-300">
{program.program_tabatas?.length || 0}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="text-neutral-400 hover:text-white"
asChild
>
<Link href={`/programs/${program.id}`}>
<Eye className="w-4 h-4" />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="text-neutral-400 hover:text-white"
asChild
>
<Link href={`/programs/${program.id}/edit`}>
<Edit className="w-4 h-4" />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="text-neutral-400 hover:text-red-500"
onClick={() => openDeleteDialog(program)}
disabled={deletingId === program.id}
>
{deletingId === program.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="bg-neutral-900 border-neutral-800 text-white">
<DialogHeader>
<DialogTitle>Delete Program</DialogTitle>
<DialogDescription className="text-neutral-400">
Are you sure you want to delete &quot;{programToDelete?.title}&quot;? This will also delete all associated tabatas. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
className="border-neutral-700 text-neutral-300 hover:bg-neutral-800"
>
Cancel
</Button>
<Button
onClick={handleDelete}
disabled={!!deletingId}
className="bg-red-500 hover:bg-red-600"
>
{deletingId ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Deleting...
</>
) : (
"Delete"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import { Metadata } from "next"
import Link from "next/link"
import { notFound } from "next/navigation"
import { ArrowLeft } from "lucide-react"
import { Button } from "@/components/ui/button"
import TrainerForm from "@/components/trainer-form"
import { supabase } from "@/lib/supabase"
interface EditTrainerPageProps {
params: Promise<{
id: string
}>
}
async function getTrainer(id: string) {
const { data, error } = await (supabase.from("trainers") as any)
.select("*")
.eq("id", id)
.single()
if (error || !data) {
return null
}
return data
}
export async function generateMetadata({ params }: EditTrainerPageProps): Promise<Metadata> {
const resolvedParams = await params
const trainer = await getTrainer(resolvedParams.id)
if (!trainer) {
return {
title: "Trainer Not Found | TabataFit Admin",
}
}
return {
title: `Edit ${trainer.name} | TabataFit Admin`,
}
}
export default async function EditTrainerPage({ params }: EditTrainerPageProps) {
const resolvedParams = await params
const trainer = await getTrainer(resolvedParams.id)
if (!trainer) {
notFound()
}
return (
<div className="p-8 max-w-2xl">
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
<Link href="/trainers">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trainers
</Link>
</Button>
<h1 className="text-2xl font-bold text-white mb-6">Edit Trainer</h1>
<TrainerForm initialData={trainer} mode="edit" />
</div>
)
}

View File

@@ -0,0 +1,10 @@
import TrainerForm from "@/components/trainer-form"
export default function NewTrainerPage() {
return (
<div className="p-8 max-w-2xl">
<h1 className="text-2xl font-bold text-white mb-6">Add New Trainer</h1>
<TrainerForm mode="create" />
</div>
)
}

View File

@@ -16,6 +16,7 @@ import { Plus, Trash2, Edit, Loader2, Users, AlertCircle } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import type { Database } from "@/lib/supabase";
import { useRouter } from "next/navigation";
type Trainer = Database["public"]["Tables"]["trainers"]["Row"];
@@ -145,12 +146,21 @@ export default function TrainersPage() {
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div
className="w-12 h-12 rounded-full flex items-center justify-center text-white font-bold"
style={{ backgroundColor: trainer.color }}
>
{trainer.name[0]}
</div>
{trainer.avatar_url ? (
<img
src={trainer.avatar_url}
alt={trainer.name}
className="w-12 h-12 rounded-full object-cover border-2"
style={{ borderColor: trainer.color }}
/>
) : (
<div
className="w-12 h-12 rounded-full flex items-center justify-center text-white font-bold"
style={{ backgroundColor: trainer.color }}
>
{trainer.name[0]}
</div>
)}
<div>
<h3 className="text-lg font-semibold text-white">{trainer.name}</h3>
<p className="text-neutral-400">{trainer.specialty}</p>
@@ -161,9 +171,11 @@ export default function TrainersPage() {
</div>
<div className="flex gap-2">
<Button variant="ghost" size="icon" className="text-neutral-400 hover:text-white">
<Edit className="w-4 h-4" />
</Button>
<Link href={`/trainers/${trainer.id}/edit`}>
<Button variant="ghost" size="icon" className="text-neutral-400 hover:text-white">
<Edit className="w-4 h-4" />
</Button>
</Link>
<Button
variant="ghost"
size="icon"

View File

@@ -14,8 +14,7 @@ interface EditWorkoutPageProps {
}
async function getWorkout(id: string) {
const { data, error } = await supabase
.from("workouts")
const { data, error } = await (supabase.from("workouts") as any)
.select("*")
.eq("id", id)
.single()

View File

@@ -0,0 +1,283 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter, useParams } from "next/navigation"
import Link from "next/link"
import { Loader2, Play, CheckCircle, AlertCircle, ArrowLeft, Video } from "lucide-react"
import { supabase } from "@/lib/supabase"
import { getWorkoutVideoStatus } from "@/lib/ai"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { toast } from "sonner"
type Workout = {
id: string
title: string
trainer_id: string
exercises: { name: string; duration: number }[]
video_url: string | null
}
type Trainer = {
id: string
name: string
avatar_url: string | null
specialty: string
}
export default function GenerateVideoPage() {
const router = useRouter()
const params = useParams()
const workoutId = params.id as string
const [workout, setWorkout] = useState<Workout | null>(null)
const [trainer, setTrainer] = useState<Trainer | null>(null)
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [progress, setProgress] = useState(0)
const [status, setStatus] = useState<"idle" | "generating" | "done" | "error">("idle")
const [videoStatus, setVideoStatus] = useState<{
hasVideo: boolean
videoUrl: string | null
inProgram: boolean
videoGenerated: boolean
} | null>(null)
useEffect(() => {
if (workoutId) {
loadData(workoutId)
}
}, [workoutId])
const loadData = async (id: string) => {
setLoading(true)
const { data: w } = await (supabase.from("workouts") as any)
.select("*")
.eq("id", id)
.single()
if (!w) {
toast.error("Workout not found")
router.push("/workouts")
return
}
setWorkout(w)
if (w.trainer_id) {
const { data: t } = await (supabase.from("trainers") as any)
.select("*")
.eq("id", w.trainer_id)
.single()
setTrainer(t)
}
const videoStatus = await getWorkoutVideoStatus(id)
setVideoStatus(videoStatus)
setLoading(false)
}
const handleGenerate = async () => {
if (!workout || !trainer) return
setStatus("generating")
setGenerating(true)
setProgress(0)
try {
const progressInterval = setInterval(() => {
setProgress(p => Math.min(p + 5, 90))
}, 3000)
const response = await fetch('/api/ai/generate-video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workoutId: workout.id,
trainerId: trainer.id,
trainerAvatarUrl: trainer.avatar_url,
exercises: workout.exercises,
}),
})
clearInterval(progressInterval)
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Generation failed')
}
const { videos, videoCount } = await response.json()
setProgress(100)
setVideoStatus(prev => prev ? { ...prev, hasVideo: true, videoUrl: videos[0]?.videoUrl } : null)
if (videoStatus?.inProgram) {
await (supabase.from("program_workouts") as any)
.update({ video_generated: true })
.eq("workout_id", workout.id)
}
setStatus("done")
toast.success(`Generated ${videoCount} exercise video(s)!`)
} catch (err) {
console.error("Generation failed:", err)
toast.error(err instanceof Error ? err.message : "Failed to generate video")
setStatus("error")
} finally {
setGenerating(false)
}
}
if (loading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
</div>
)
}
if (!workout) {
return (
<div className="p-8 text-center">
<p className="text-neutral-400">Workout not found</p>
</div>
)
}
const needsGeneration = !videoStatus?.hasVideo
return (
<div className="p-8 max-w-3xl">
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
<Link href={`/workouts/${workout.id}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Workout
</Link>
</Button>
<h1 className="text-2xl font-bold text-white mb-6">Generate Workout Video</h1>
<Card className="bg-neutral-900 border-neutral-800">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-white">{workout.title}</CardTitle>
{videoStatus?.inProgram && (
<Badge variant="outline" className="border-amber-500 text-amber-500">
In Program
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center gap-4 p-4 rounded-lg bg-neutral-800">
{trainer?.avatar_url && (
<img
src={trainer.avatar_url}
alt={trainer.name}
className="w-16 h-16 rounded-full object-cover border-2 border-neutral-700"
/>
)}
<div>
<p className="font-medium text-white">{trainer?.name}</p>
<p className="text-sm text-neutral-400">{trainer?.specialty}</p>
</div>
</div>
{videoStatus?.inProgram && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertCircle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div className="text-sm">
<p className="text-amber-500 font-medium">Workout is in a program</p>
<p className="text-neutral-400">
The video_generated flag will be set to true after generation.
</p>
</div>
</div>
)}
<div>
<h3 className="font-medium text-white mb-3">Exercises</h3>
<div className="space-y-2">
{workout.exercises?.map((ex, i) => (
<div key={i} className="flex items-center gap-3 text-sm">
<span className="w-8 h-8 rounded-full bg-neutral-800 flex items-center justify-center text-neutral-400 text-xs">
{i + 1}
</span>
<span className="text-neutral-300">{ex.name}</span>
<span className="text-neutral-500 ml-auto">{ex.duration}s</span>
</div>
))}
</div>
</div>
{videoStatus?.hasVideo && videoStatus.videoUrl && !generating && (
<div>
<h3 className="font-medium text-white mb-3">Generated Video</h3>
<video
src={videoStatus.videoUrl}
controls
autoPlay
loop
className="w-full max-h-80 rounded-lg border border-neutral-700"
/>
<p className="text-xs text-neutral-500 mt-2">
Video loops seamlessly (8 seconds)
</p>
</div>
)}
{needsGeneration && (
<div className="pt-4 border-t border-neutral-800">
{status === "generating" ? (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Loader2 className="w-5 h-5 animate-spin text-purple-500" />
<span className="text-white">Generating video with Veo 3.1...</span>
</div>
<div className="h-2 w-full rounded-full bg-neutral-800">
<div
className="h-full bg-purple-500 transition-all duration-1000"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center gap-2 text-sm text-neutral-400">
<Video className="w-4 h-4" />
<span>Creating 8-second looping video with {trainer?.name}</span>
</div>
<p className="text-xs text-neutral-500">
This may take up to 2 minutes. Please wait...
</p>
</div>
) : (
<Button
onClick={handleGenerate}
className="w-full bg-purple-500 hover:bg-purple-600"
>
<Play className="w-4 h-4" />
Generate 8s Looping Video
</Button>
)}
</div>
)}
{status === "done" && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle className="w-5 h-5 text-green-500" />
<span className="text-green-500 font-medium">Video generated and saved!</span>
</div>
)}
{status === "error" && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
<AlertCircle className="w-5 h-5 text-red-500" />
<span className="text-red-500">Video generation failed. Check console for details.</span>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { Metadata } from "next"
import Link from "next/link"
import { notFound } from "next/navigation"
import { ArrowLeft, Edit, Trash2, Clock, Flame, Dumbbell, Music } from "lucide-react"
import { ArrowLeft, Edit, Trash2, Clock, Flame, Dumbbell, Music, Video } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@@ -23,8 +23,7 @@ const CATEGORY_COLORS: Record<string, string> = {
}
async function getWorkout(id: string) {
const { data, error } = await supabase
.from("workouts")
const { data, error } = await (supabase.from("workouts") as any)
.select(`
*,
trainers (name, specialty, color)
@@ -95,6 +94,12 @@ export default async function WorkoutDetailPage({ params }: WorkoutDetailPagePro
</div>
<div className="flex gap-2">
<Button variant="outline" asChild className="border-neutral-700">
<Link href={`/workouts/${workout.id}/generate-video`}>
<Video className="mr-2 h-4 w-4" />
Generate Video
</Link>
</Button>
<Button variant="outline" asChild className="border-neutral-700">
<Link href={`/workouts/${workout.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />

View File

@@ -0,0 +1,12 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Apr 16, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6321 | 9:38 PM | 🟣 | Created TabataEditor component for admin workout program management | ~327 |
| #6325 | " | 🟣 | Added Programs navigation link to admin sidebar | ~260 |
</claude-mem-context>

View File

@@ -0,0 +1,575 @@
"use client"
import * as React from "react"
import { useRouter } from "next/navigation"
import { Loader2, Save, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { supabase } from "@/lib/supabase"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
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[]
warmup?: WarmupRow[]
stretch?: StretchRow[]
}
mode?: "create" | "edit"
}
const BODY_ZONE_OPTIONS = [
{ value: "upper-body", label: "Upper Body" },
{ value: "lower-body", label: "Lower Body" },
{ value: "full-body", label: "Full Body" },
]
const LEVEL_OPTIONS = [
{ value: "Beginner", label: "Beginner" },
{ value: "Intermediate", label: "Intermediate" },
{ value: "Advanced", label: "Advanced" },
]
export default function ProgramForm({ initialData, mode = "create" }: ProgramFormProps) {
const router = useRouter()
const [isLoading, setIsLoading] = React.useState(false)
const [errors, setErrors] = React.useState<Record<string, string>>({})
// Basics state
const [title, setTitle] = React.useState(initialData?.title || "")
const [description, setDescription] = React.useState(initialData?.description || "")
const [bodyZone, setBodyZone] = React.useState(initialData?.body_zone || "full-body")
const [level, setLevel] = React.useState(initialData?.level || "Beginner")
const [isFree, setIsFree] = React.useState(initialData?.is_free || false)
const [estimatedCalories, setEstimatedCalories] = React.useState(
String(initialData?.estimated_calories || "")
)
const [icon, setIcon] = React.useState(initialData?.icon || "")
const [accentColor, setAccentColor] = React.useState(initialData?.accent_color || "")
const [sortOrder, setSortOrder] = React.useState(
String(initialData?.sort_order ?? "0")
)
// Tabatas state
const [tabatas, setTabatas] = React.useState<TabataData[]>(() => {
if (initialData?.tabatas && initialData.tabatas.length > 0) {
return initialData.tabatas
.sort((a, b) => a.position - b.position)
.map((t) => ({
position: t.position,
exercise_1_name: t.exercise_1_name || "",
exercise_1_name_en: t.exercise_1_name_en || "",
exercise_1_tip: t.exercise_1_tip || "",
exercise_1_tip_en: t.exercise_1_tip_en || "",
exercise_1_modification: t.exercise_1_modification || "",
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 || "",
exercise_2_tip_en: t.exercise_2_tip_en || "",
exercise_2_modification: t.exercise_2_modification || "",
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_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<TimedExerciseData[]>(() => {
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<TimedExerciseData[]>(() => {
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<string, string> = {}
if (!title.trim()) newErrors.title = "Title is required"
tabatas.forEach((tabata, i) => {
if (!tabata.exercise_1_name.trim()) {
newErrors[`tabata_${i + 1}_ex1`] = `Tabata ${i + 1}: Exercise 1 name is required`
}
if (!tabata.exercise_2_name.trim()) {
newErrors[`tabata_${i + 1}_ex2`] = `Tabata ${i + 1}: Exercise 2 name is required`
}
})
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
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validate()) {
return
}
setIsLoading(true)
try {
// Calculate total estimated duration from tabatas
const totalSeconds = tabatas.reduce(
(sum, t) => sum + t.rounds * (t.work_time + t.rest_time),
0
)
const estimatedDuration = Math.ceil(totalSeconds / 60)
const programData = {
title: title.trim(),
description: description.trim(),
body_zone: bodyZone as WorkoutProgram["body_zone"],
level: level as WorkoutProgram["level"],
is_free: isFree,
estimated_duration: estimatedDuration,
estimated_calories: parseInt(estimatedCalories) || 0,
icon: icon.trim() || null,
accent_color: accentColor.trim() || null,
sort_order: parseInt(sortOrder) || 0,
}
let programId: string
if (mode === "edit" && initialData) {
const result = await (supabase.from("workout_programs") as any)
.update(programData)
.eq("id", initialData.id)
.select()
.single()
if (result.error) throw result.error
programId = initialData.id
} else {
const result = await (supabase.from("workout_programs") as any)
.insert(programData)
.select()
.single()
if (result.error) throw result.error
programId = result.data.id
}
// Upsert tabatas
for (const tabata of tabatas) {
const tabataPayload = {
program_id: programId,
position: tabata.position,
exercise_1_name: tabata.exercise_1_name.trim(),
exercise_1_name_en: tabata.exercise_1_name_en.trim() || null,
exercise_1_tip: tabata.exercise_1_tip.trim() || null,
exercise_1_tip_en: tabata.exercise_1_tip_en.trim() || null,
exercise_1_modification: tabata.exercise_1_modification.trim() || null,
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,
exercise_2_tip_en: tabata.exercise_2_tip_en.trim() || null,
exercise_2_modification: tabata.exercise_2_modification.trim() || null,
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,
}
// In edit mode, check if tabata exists for this position
if (mode === "edit") {
const existing = initialData?.tabatas?.find((t) => t.position === tabata.position)
if (existing) {
const { error } = await (supabase.from("program_tabatas") as any)
.update(tabataPayload)
.eq("id", existing.id)
if (error) throw error
} else {
const { error } = await (supabase.from("program_tabatas") as any)
.insert(tabataPayload)
if (error) throw error
}
} else {
const { error } = await (supabase.from("program_tabatas") as any)
.insert(tabataPayload)
if (error) throw error
}
}
// 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.`
})
router.push(`/programs/${programId}`)
} catch (err) {
console.error("Failed to save program:", err)
toast.error("Failed to save program. Please try again.")
} finally {
setIsLoading(false)
}
}
const handleCancel = () => {
if (mode === "edit" && initialData) {
router.push(`/programs/${initialData.id}`)
} else {
router.push("/programs")
}
}
const handleTabataChange = (position: number, data: TabataData) => {
setTabatas((prev) =>
prev.map((t) => (t.position === position ? { ...data, position } : t))
)
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<Tabs defaultValue="basics" className="w-full">
<TabsList className="grid w-full grid-cols-4 bg-neutral-900">
<TabsTrigger value="basics" className="data-[state=active]:bg-neutral-800">
Basics
</TabsTrigger>
<TabsTrigger value="warmup" className="data-[state=active]:bg-neutral-800">
Warmup
</TabsTrigger>
<TabsTrigger value="tabatas" className="data-[state=active]:bg-neutral-800">
Tabatas
</TabsTrigger>
<TabsTrigger value="stretch" className="data-[state=active]:bg-neutral-800">
Stretch
</TabsTrigger>
</TabsList>
{/* Tab 1: Basics */}
<TabsContent value="basics" className="space-y-4">
<div className="grid gap-4">
<div className="space-y-2">
<Label htmlFor="title">Program Title *</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Full Body Blast"
className={cn(errors.title && "border-red-500")}
/>
{errors.title && <p className="text-xs text-red-500">{errors.title}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe this program..."
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="bodyZone">Body Zone *</Label>
<Select
id="bodyZone"
value={bodyZone}
onValueChange={(value) => setBodyZone(value as typeof bodyZone)}
options={BODY_ZONE_OPTIONS}
/>
</div>
<div className="space-y-2">
<Label htmlFor="level">Level *</Label>
<Select
id="level"
value={level}
onValueChange={(value) => setLevel(value as typeof level)}
options={LEVEL_OPTIONS}
/>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border border-neutral-800 bg-neutral-900/50 p-4">
<div className="space-y-0.5">
<Label htmlFor="isFree" className="text-base">
Free Program
</Label>
<p className="text-sm text-neutral-500">
Make this program available to free users
</p>
</div>
<Switch id="isFree" checked={isFree} onCheckedChange={setIsFree} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="estimatedCalories">Estimated Calories</Label>
<Input
id="estimatedCalories"
type="number"
value={estimatedCalories}
onChange={(e) => setEstimatedCalories(e.target.value)}
min={0}
placeholder="e.g., 120"
/>
</div>
<div className="space-y-2">
<Label htmlFor="sortOrder">Sort Order</Label>
<Input
id="sortOrder"
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
min={0}
placeholder="0"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="icon">Icon Name</Label>
<Input
id="icon"
value={icon}
onChange={(e) => setIcon(e.target.value)}
placeholder="e.g., flame, dumbbell"
/>
</div>
<div className="space-y-2">
<Label htmlFor="accentColor">Accent Color</Label>
<div className="flex gap-2">
<Input
id="accentColor"
value={accentColor}
onChange={(e) => setAccentColor(e.target.value)}
placeholder="#FF6B35"
className="flex-1"
/>
{accentColor && (
<div
className="h-10 w-10 rounded-md border border-neutral-700 flex-shrink-0"
style={{ backgroundColor: accentColor }}
/>
)}
</div>
</div>
</div>
</div>
</TabsContent>
{/* Tab 2: Warmup */}
<TabsContent value="warmup" className="space-y-4">
<TimedExerciseList
title="Warmup exercises"
description="Dynamic warmup sequence before the tabatas. Amber accent in the player."
emptyLabel="No warmup exercises yet. Add the first one to get started."
accentColor="text-amber-400"
items={warmup}
onChange={setWarmup}
errors={Object.fromEntries(
warmup.map((_, i) => [i, errors[`warmup_${i}`] || errors[`warmup_${i}_dur`] || ""]).filter(([, v]) => v)
)}
/>
</TabsContent>
{/* Tab 3: Tabatas */}
<TabsContent value="tabatas" className="space-y-4">
<div className="space-y-1 mb-4">
<p className="text-sm text-neutral-500">
Each program has 3 tabatas (exercise pairs). Every tabata alternates between two exercises for the specified number of rounds.
</p>
</div>
{errors.tabata_1_ex1 && (
<p className="text-xs text-red-500 bg-red-500/10 px-3 py-2 rounded-md">
{errors.tabata_1_ex1}
</p>
)}
{errors.tabata_1_ex2 && (
<p className="text-xs text-red-500 bg-red-500/10 px-3 py-2 rounded-md">
{errors.tabata_1_ex2}
</p>
)}
{([1, 2, 3] as const).map((pos) => {
const tabata = tabatas.find((t) => t.position === pos) || null
return (
<TabataEditor
key={pos}
tabata={tabata}
position={pos}
onChange={(data) => handleTabataChange(pos, data)}
/>
)
})}
</TabsContent>
{/* Tab 4: Stretch */}
<TabsContent value="stretch" className="space-y-4">
<TimedExerciseList
title="Stretch exercises"
description="Cool-down stretches after the tabatas. Lavender accent in the player."
emptyLabel="No stretch exercises yet. Add the first one to get started."
accentColor="text-violet-300"
items={stretch}
onChange={setStretch}
errors={Object.fromEntries(
stretch.map((_, i) => [i, errors[`stretch_${i}`] || errors[`stretch_${i}_dur`] || ""]).filter(([, v]) => v)
)}
/>
</TabsContent>
</Tabs>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4 border-t border-neutral-800">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isLoading}
className="border-neutral-700 text-neutral-400 hover:bg-neutral-800 hover:text-white"
>
<X className="mr-2 h-4 w-4" />
Cancel
</Button>
<Button
type="submit"
disabled={isLoading}
className="bg-orange-500 hover:bg-orange-600"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{mode === "edit" ? "Update Program" : "Create Program"}
</>
)}
</Button>
</div>
</form>
)
}

View File

@@ -13,11 +13,13 @@ import {
Music,
LogOut,
Flame,
LayoutGrid,
} from "lucide-react";
const navItems = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/workouts", label: "Workouts", icon: Dumbbell },
{ href: "/programs", label: "Programs", icon: LayoutGrid },
{ href: "/trainers", label: "Trainers", icon: Users },
{ href: "/collections", label: "Collections", icon: FolderOpen },
{ href: "/media", label: "Media", icon: ImageIcon },

View File

@@ -0,0 +1,311 @@
"use client"
import * as React from "react"
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"]
interface TabataData {
position: number
exercise_1_name: string
exercise_1_name_en: string
exercise_1_tip: string
exercise_1_tip_en: string
exercise_1_modification: string
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
exercise_2_tip_en: string
exercise_2_modification: string
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
}
export type { TabataData }
interface TabataEditorProps {
tabata: TabataData | null
position: 1 | 2 | 3
onChange: (data: TabataData) => void
}
function getDefaultTabata(position: number): TabataData {
return {
position,
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,
}
}
interface ExerciseSectionProps {
label: string
number: 1 | 2
data: TabataData
onChange: (field: string, value: string) => void
errors: Record<string, string>
}
function ExerciseSection({ label, number, data, onChange, errors }: ExerciseSectionProps) {
const [isOpen, setIsOpen] = React.useState(true)
const prefix = `exercise_${number}` as const
const nameField = `${prefix}_name` as keyof TabataData
const nameValue = data[nameField] as string
return (
<div className="rounded-lg border border-neutral-800 bg-neutral-950 overflow-hidden">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-between w-full px-4 py-3 text-sm font-medium text-white hover:bg-neutral-800/50 transition-colors"
>
<div className="flex items-center gap-2">
{isOpen ? <ChevronDown className="h-4 w-4 text-neutral-500" /> : <ChevronRight className="h-4 w-4 text-neutral-500" />}
<Dumbbell className="h-4 w-4 text-orange-500" />
<span>{label}</span>
{nameValue && (
<span className="text-neutral-500 font-normal">- {nameValue}</span>
)}
</div>
</button>
{isOpen && (
<div className="px-4 pb-4 space-y-3 border-t border-neutral-800">
{/* Name fields */}
<div className="grid grid-cols-2 gap-3 pt-3">
<div className="space-y-1.5">
<Label className="text-xs text-neutral-400">Name (FR) *</Label>
<Input
value={data[nameField] as string}
onChange={(e) => onChange(`${prefix}_name`, e.target.value)}
placeholder="e.g., Squats"
className={cn("h-9 text-sm", errors[`${prefix}_name`] && "border-red-500")}
/>
{errors[`${prefix}_name`] && <p className="text-xs text-red-500">{errors[`${prefix}_name`]}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-xs text-neutral-400">Name (EN)</Label>
<Input
value={data[`${prefix}_name_en` as keyof TabataData] as string}
onChange={(e) => onChange(`${prefix}_name_en`, e.target.value)}
placeholder="e.g., Squats"
className="h-9 text-sm"
/>
</div>
</div>
{/* Tip fields */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs text-neutral-400">Tip (FR)</Label>
<Input
value={data[`${prefix}_tip` as keyof TabataData] as string}
onChange={(e) => onChange(`${prefix}_tip`, e.target.value)}
placeholder="Conseil en français"
className="h-9 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-neutral-400">Tip (EN)</Label>
<Input
value={data[`${prefix}_tip_en` as keyof TabataData] as string}
onChange={(e) => onChange(`${prefix}_tip_en`, e.target.value)}
placeholder="Tip in English"
className="h-9 text-sm"
/>
</div>
</div>
{/* Modification fields */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs text-neutral-400">Modification (FR)</Label>
<Input
value={data[`${prefix}_modification` as keyof TabataData] as string}
onChange={(e) => onChange(`${prefix}_modification`, e.target.value)}
placeholder="Version plus facile"
className="h-9 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-neutral-400">Modification (EN)</Label>
<Input
value={data[`${prefix}_modification_en` as keyof TabataData] as string}
onChange={(e) => onChange(`${prefix}_modification_en`, e.target.value)}
placeholder="Easier variation"
className="h-9 text-sm"
/>
</div>
</div>
{/* Progression fields */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs text-neutral-400">Progression (FR)</Label>
<Input
value={data[`${prefix}_progression` as keyof TabataData] as string}
onChange={(e) => onChange(`${prefix}_progression`, e.target.value)}
placeholder="Version plus difficile"
className="h-9 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-neutral-400">Progression (EN)</Label>
<Input
value={data[`${prefix}_progression_en` as keyof TabataData] as string}
onChange={(e) => onChange(`${prefix}_progression_en`, e.target.value)}
placeholder="Harder variation"
className="h-9 text-sm"
/>
</div>
</div>
{/* Background video */}
<div className="space-y-1.5">
<Label className="text-xs text-neutral-400">Background Video</Label>
<MediaUpload
type="video"
value={(data[`${prefix}_video_url` as keyof TabataData] as string) || undefined}
onChange={(url) => onChange(`${prefix}_video_url`, url)}
/>
</div>
</div>
)}
</div>
)
}
export default function TabataEditor({ tabata, position, onChange }: TabataEditorProps) {
const data = tabata || getDefaultTabata(position)
const [errors, setErrors] = React.useState<Record<string, string>>({})
const handleChange = (field: string, value: string) => {
const updated = { ...data, [field]: value }
onChange(updated)
}
const handleTimingChange = (field: "rounds" | "work_time" | "rest_time", value: string) => {
const numValue = parseInt(value) || 0
const updated = { ...data, [field]: numValue }
onChange(updated)
}
return (
<div className="rounded-lg border border-neutral-700 bg-neutral-900 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 bg-neutral-800/50 border-b border-neutral-700">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-orange-500/20 text-orange-500 font-bold text-sm">
{position}
</div>
<div>
<h3 className="text-white font-medium">Tabata {position}</h3>
<p className="text-xs text-neutral-500">
{data.exercise_1_name && data.exercise_2_name
? `${data.exercise_1_name} / ${data.exercise_2_name}`
: "Configure exercises and timing"
}
</p>
</div>
<div className="ml-auto flex items-center gap-2 text-xs text-neutral-500">
<Clock className="h-3.5 w-3.5" />
<span>{data.rounds * (data.work_time + data.rest_time)}s total</span>
</div>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Exercise sections */}
<ExerciseSection
label="Exercise 1"
number={1}
data={data}
onChange={handleChange}
errors={errors}
/>
<ExerciseSection
label="Exercise 2"
number={2}
data={data}
onChange={handleChange}
errors={errors}
/>
{/* Timing */}
<div className="rounded-lg border border-neutral-800 bg-neutral-950 p-4">
<div className="flex items-center gap-2 mb-3">
<Clock className="h-4 w-4 text-orange-500" />
<span className="text-sm font-medium text-white">Timing</span>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label className="text-xs text-neutral-400">Rounds</Label>
<Input
type="number"
value={data.rounds}
onChange={(e) => handleTimingChange("rounds", e.target.value)}
min={1}
max={20}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-neutral-400">Work (sec)</Label>
<Input
type="number"
value={data.work_time}
onChange={(e) => handleTimingChange("work_time", e.target.value)}
min={1}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-neutral-400">Rest (sec)</Label>
<Input
type="number"
value={data.rest_time}
onChange={(e) => handleTimingChange("rest_time", e.target.value)}
min={0}
className="h-9 text-sm"
/>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -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 = <K extends keyof TimedExerciseData>(key: K, value: TimedExerciseData[K]) => {
onChange({ ...data, [key]: value })
}
return (
<div className="rounded-lg border border-neutral-800 bg-neutral-950 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-2 bg-neutral-900 border-b border-neutral-800">
<div className={cn("flex h-6 w-6 items-center justify-center rounded-full bg-neutral-800 text-xs font-semibold", accentColor)}>
{index + 1}
</div>
<span className="text-sm font-medium text-white flex-1">
{data.name || `Exercise ${index + 1}`}
</span>
<div className="flex items-center gap-1 text-xs text-neutral-500 mr-2">
<Clock className="h-3 w-3" />
{data.duration}s
</div>
<div className="flex items-center gap-0.5">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onMoveUp}
disabled={index === 0}
className="h-7 w-7 p-0 text-neutral-400 hover:text-white disabled:opacity-30"
>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onMoveDown}
disabled={index === total - 1}
className="h-7 w-7 p-0 text-neutral-400 hover:text-white disabled:opacity-30"
>
<ArrowDown className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onRemove}
className="h-7 w-7 p-0 text-red-400 hover:bg-red-500/10 hover:text-red-400"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* Fields */}
<div className="p-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs text-neutral-400">Name (FR) *</Label>
<Input
value={data.name}
onChange={(e) => update("name", e.target.value)}
placeholder="e.g., Jumping Jacks"
className={cn("h-9 text-sm", error && "border-red-500")}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-neutral-400">Name (EN)</Label>
<Input
value={data.name_en}
onChange={(e) => update("name_en", e.target.value)}
placeholder="e.g., Jumping Jacks"
className="h-9 text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs text-neutral-400">Tip (FR)</Label>
<Input
value={data.tip}
onChange={(e) => update("tip", e.target.value)}
placeholder="Conseil en français"
className="h-9 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-neutral-400">Tip (EN)</Label>
<Input
value={data.tip_en}
onChange={(e) => update("tip_en", e.target.value)}
placeholder="Tip in English"
className="h-9 text-sm"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-neutral-400">Duration (seconds) *</Label>
<Input
type="number"
value={data.duration}
onChange={(e) => update("duration", parseInt(e.target.value) || 0)}
min={1}
max={300}
className="h-9 text-sm w-32"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-neutral-400">Background Video</Label>
<MediaUpload
type="video"
value={data.video_url || undefined}
onChange={(url) => update("video_url", url)}
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
</div>
)
}
export function createEmptyTimedExercise(position: number): TimedExerciseData {
return {
position,
name: "",
name_en: "",
tip: "",
tip_en: "",
duration: 30,
video_url: "",
}
}

View File

@@ -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<number, string>
}
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 (
<div className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-medium text-white">{title}</h3>
<p className="text-sm text-neutral-500">{description}</p>
</div>
{items.length === 0 ? (
<div className="rounded-lg border border-dashed border-neutral-800 bg-neutral-950/50 p-8 text-center">
<p className="text-sm text-neutral-500 mb-4">{emptyLabel}</p>
<Button type="button" onClick={addItem} variant="outline" className="border-neutral-700">
<Plus className="mr-2 h-4 w-4" />
Add exercise
</Button>
</div>
) : (
<>
{items.map((item, i) => (
<TimedExerciseRow
key={i}
data={item}
index={i}
total={items.length}
accentColor={accentColor}
onChange={(data) => updateItem(i, data)}
onRemove={() => removeItem(i)}
onMoveUp={() => move(i, i - 1)}
onMoveDown={() => move(i, i + 1)}
error={errors[i]}
/>
))}
<Button type="button" onClick={addItem} variant="outline" className="w-full border-neutral-700 border-dashed">
<Plus className="mr-2 h-4 w-4" />
Add exercise
</Button>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,352 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Loader2, Sparkles, Save, X, Upload } from "lucide-react"
import { cn } from "@/lib/utils"
import { supabase } from "@/lib/supabase"
import type { Database } from "@/lib/supabase"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select } from "@/components/ui/select"
import { toast } from "sonner"
type Trainer = Database["public"]["Tables"]["trainers"]["Row"]
type TrainerInsert = Database["public"]["Tables"]["trainers"]["Insert"]
type TrainerUpdate = Database["public"]["Tables"]["trainers"]["Update"]
interface TrainerFormProps {
initialData?: Trainer
mode?: "create" | "edit"
}
const SPECIALTY_OPTIONS = [
{ value: "Core", label: "Core" },
{ value: "Strength", label: "Strength" },
{ value: "Cardio", label: "Cardio" },
{ value: "Full Body", label: "Full Body" },
{ value: "Recovery", label: "Recovery" },
]
const PRESET_COLORS = [
"#FF6B35", "#FFD60A", "#30D158", "#5AC8FA", "#BF5AF2",
"#FF9500", "#FF2D55", "#5856D6", "#FF3B30", "#34C759",
]
export default function TrainerForm({ initialData, mode = "create" }: TrainerFormProps) {
const router = useRouter()
const [isLoading, setIsLoading] = useState(false)
const [isGenerating, setIsGenerating] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const [generatedImages, setGeneratedImages] = useState<{ buffer: Buffer; url?: string }[]>([])
const [selectedImage, setSelectedImage] = useState<string | null>(null)
const [generatingPrompt, setGeneratingPrompt] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [avatarFile, setAvatarFile] = useState<File | null>(null)
const [name, setName] = useState(initialData?.name || "")
const [specialty, setSpecialty] = useState(initialData?.specialty || "Core")
const [color, setColor] = useState(initialData?.color || "#FF6B35")
const [avatarUrl, setAvatarUrl] = useState(initialData?.avatar_url || "")
const validate = () => {
const newErrors: Record<string, string> = {}
if (!name.trim()) newErrors.name = "Name is required"
if (!specialty) newErrors.specialty = "Specialty is required"
if (!color) newErrors.color = "Color is required"
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleGenerateImages = async () => {
if (!name.trim()) {
toast.error("Please enter a name first")
return
}
const prompt = generatingPrompt ||
`Professional portrait photo of ${name}, fitness trainer, friendly smile, gym setting, high quality, facing camera directly, energetic and professional`
setIsGenerating(true)
try {
const trainerId = initialData?.id || "new-trainer"
const filename = `avatar_${Date.now()}.png`
const response = await fetch('/api/ai/generate-avatar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, trainerId, filename }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Generation failed')
}
const { url } = await response.json()
const savedImages = [{ buffer: Buffer.alloc(0), url }]
setGeneratedImages(savedImages)
setSelectedImage(url)
setAvatarUrl(url)
toast.success('Avatar generated')
} catch (err) {
console.error("Generation failed:", err)
toast.error(err instanceof Error ? err.message : "Failed to generate images")
} finally {
setIsGenerating(false)
}
}
const handleSelectGenerated = (url: string) => {
setSelectedImage(url)
setAvatarUrl(url)
setAvatarFile(null)
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setAvatarFile(file)
setSelectedImage(null)
setIsUploading(true)
try {
const trainerId = initialData?.id || "new-trainer"
const filename = `upload_${Date.now()}.png`
const path = `${trainerId}/${filename}`
const { error } = await supabase.storage
.from('trainer-avatars')
.upload(path, file, {
contentType: file.type,
upsert: true,
})
if (error) throw new Error(error.message)
const { data: { publicUrl } } = supabase.storage
.from('trainer-avatars')
.getPublicUrl(path)
setAvatarUrl(publicUrl)
setGeneratedImages([])
toast.success("Avatar uploaded")
} catch (err) {
console.error("Upload failed:", err)
toast.error(err instanceof Error ? err.message : "Failed to upload avatar")
} finally {
setIsUploading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validate()) return
setIsLoading(true)
try {
const trainerData = {
name: name.trim(),
specialty,
color,
avatar_url: avatarUrl || null,
workout_count: initialData?.workout_count || 0,
}
let result
if (mode === "edit" && initialData) {
result = await (supabase
.from("trainers") as any)
.update(trainerData)
.eq("id", initialData.id)
.select()
.single()
} else {
result = await (supabase
.from("trainers") as any)
.insert(trainerData)
.select()
.single()
}
if (result.error) throw result.error
toast.success(mode === "edit" ? "Trainer updated" : "Trainer created")
router.push("/trainers")
} catch (err) {
console.error("Failed to save:", err)
toast.error("Failed to save trainer")
} finally {
setIsLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-8">
<div className="space-y-4">
<h2 className="text-lg font-semibold text-white">Basic Information</h2>
<div className="grid gap-4">
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Félia"
className={cn(errors.name && "border-red-500")}
/>
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="specialty">Specialty *</Label>
<Select
id="specialty"
value={specialty}
onValueChange={setSpecialty}
options={SPECIALTY_OPTIONS}
/>
</div>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex gap-2 flex-wrap">
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={cn(
"w-8 h-8 rounded-full border-2 transition-all",
color === c ? "border-white scale-110" : "border-transparent"
)}
style={{ backgroundColor: c }}
/>
))}
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-8 h-8 rounded-full cursor-pointer"
/>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-white">Trainer Avatar</h2>
<div className="space-y-2">
<Label>Generate with AI (Nano Banana Pro)</Label>
<div className="flex gap-2">
<Input
value={generatingPrompt}
onChange={(e) => setGeneratingPrompt(e.target.value)}
placeholder={`Portrait of ${name || 'trainer'}, fitness, friendly...`}
className="flex-1"
/>
<Button
type="button"
onClick={handleGenerateImages}
disabled={isGenerating || !name.trim()}
className="bg-purple-500 hover:bg-purple-600"
>
{isGenerating ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Sparkles className="w-4 h-4" />
)}
Generate
</Button>
</div>
</div>
{generatedImages.length > 0 && (
<div className="space-y-2">
<Label>Select Generated Image</Label>
<div className="grid grid-cols-3 gap-4">
{generatedImages.map((img, i) => (
<div
key={i}
onClick={() => img.url && handleSelectGenerated(img.url)}
className={cn(
"relative aspect-square rounded-lg overflow-hidden cursor-pointer border-2 transition-all",
selectedImage === img.url ? "border-orange-500 scale-105" : "border-transparent hover:border-neutral-600"
)}
>
{img.url && <img src={img.url} alt={`Generated ${i + 1}`} className="w-full h-full object-cover" />}
</div>
))}
</div>
</div>
)}
<div className="space-y-2">
<Label>Or Upload Custom Image</Label>
<div className="flex items-center gap-4">
<Button
type="button"
variant="outline"
onClick={() => document.getElementById('avatar-upload')?.click()}
disabled={isUploading}
className="border-neutral-700"
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Upload className="w-4 h-4" />
)}
Choose File
</Button>
<input
id="avatar-upload"
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleFileChange}
className="hidden"
/>
{avatarFile && <span className="text-sm text-neutral-400">{avatarFile.name}</span>}
</div>
</div>
{avatarUrl && (
<div className="space-y-2">
<Label>Current Avatar</Label>
<div className="relative w-32 h-32">
<img
src={avatarUrl}
alt="Current avatar"
className="w-full h-full object-cover rounded-full border-2 border-neutral-700"
/>
{selectedImage === avatarUrl && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full">
<Sparkles className="w-8 h-8 text-orange-500" />
</div>
)}
</div>
</div>
)}
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-neutral-800">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
className="border-neutral-700"
>
Cancel
</Button>
<Button type="submit" disabled={isLoading} className="bg-orange-500 hover:bg-orange-600">
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
{mode === "edit" ? "Update Trainer" : "Create Trainer"}
</Button>
</div>
</form>
)
}

View File

@@ -163,15 +163,13 @@ export default function WorkoutForm({ initialData, mode = "create" }: WorkoutFor
let result
if (mode === "edit" && initialData) {
result = await supabase
.from("workouts")
result = await (supabase.from("workouts") as any)
.update(workoutData)
.eq("id", initialData.id)
.select()
.single()
} else {
result = await supabase
.from("workouts")
result = await (supabase.from("workouts") as any)
.insert(workoutData)
.select()
.single()
@@ -199,8 +197,7 @@ export default function WorkoutForm({ initialData, mode = "create" }: WorkoutFor
}
if (Object.keys(updateData).length > 0) {
await supabase
.from("workouts")
await (supabase.from("workouts") as any)
.update(updateData)
.eq("id", result.data.id)
}

15
admin-web/lib/CLAUDE.md Normal file
View File

@@ -0,0 +1,15 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Apr 16, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6329 | 9:39 PM | 🔄 | Refactored admin AI library to remove legacy program_workouts table references | ~356 |
| #6328 | " | 🔄 | Removed ProgramWorkoutRow type definition from admin AI integration | ~364 |
| #6327 | 9:38 PM | 🔵 | Identified legacy database schema references in admin AI library | ~316 |
| #6326 | " | 🔵 | Discovered AI integration file with legacy database schema references | ~336 |
| #6324 | " | 🔄 | Updated Supabase database type definitions for workout program schema | ~457 |
</claude-mem-context>

171
admin-web/lib/ai.ts Normal file
View File

@@ -0,0 +1,171 @@
import { GoogleGenAI } from '@google/genai'
import { supabase } from './supabase'
import type { Database } from './supabase'
type WorkoutRow = Database['public']['Tables']['workouts']['Row']
function getAI(): GoogleGenAI {
const apiKey = process.env['GEMINI_API_KEY']
if (!apiKey) {
throw new Error('GEMINI_API_KEY environment variable is not set')
}
return new GoogleGenAI({ apiKey })
}
export interface GeneratedImage {
buffer: Buffer
mimeType: string
}
export interface VideoGenerationResult {
workoutId: string
trainerId: string
videoUrl: string
videoPath: string
}
export async function generateTrainerAvatar(
prompt: string,
_trainerName: string
): Promise<GeneratedImage[]> {
const config = {
imageConfig: {
aspectRatio: '1:1',
imageSize: '1K',
personGeneration: 'ALLOW_ADULT',
},
responseModalities: ['IMAGE', 'TEXT'],
}
const response = await getAI().models.generateContentStream({
model: 'gemini-3.1-flash-image-preview',
config,
contents: [{ role: 'user', parts: [{ text: prompt }] }],
})
const images: GeneratedImage[] = []
for await (const chunk of response) {
const parts = chunk.candidates?.[0]?.content?.parts
if (!parts) continue
for (const part of parts) {
if (part.inlineData) {
images.push({
buffer: Buffer.from(part.inlineData.data || '', 'base64'),
mimeType: part.inlineData.mimeType || 'image/png',
})
}
}
}
return images
}
export async function saveAvatarToStorage(
trainerId: string,
imageBuffer: Buffer,
filename: string
): Promise<string> {
const path = `${trainerId}/${filename}`
const { error } = await supabase.storage
.from('trainer-avatars')
.upload(path, imageBuffer, {
contentType: 'image/png',
upsert: true,
})
if (error) throw new Error(`Failed to upload avatar: ${error.message}`)
const { data: { publicUrl } } = supabase.storage
.from('trainer-avatars')
.getPublicUrl(path)
return publicUrl
}
export async function generateWorkoutVideo(
workoutId: string,
trainerId: string,
_trainerAvatarUrl: string,
workoutTitle: string,
exercises: { name: string; duration: number }[]
): Promise<VideoGenerationResult> {
const exerciseList = exercises.map(e => e.name).join(', ')
const prompt = `${workoutTitle}. Exercises: ${exerciseList}. The video must loop seamlessly - start and end in the exact same standing position with arms at sides. Person should appear energetic and properly demonstrate each exercise. 16:9 aspect ratio.`
const operation = await getAI().models.generateVideos({
model: 'veo-3.1-fast-generate-preview',
source: { prompt },
config: {
numberOfVideos: 1,
aspectRatio: '16:9',
resolution: '1080p',
personGeneration: 'dont_allow',
durationSeconds: 8,
},
})
let currentOperation = operation
while (!currentOperation.done) {
await new Promise(resolve => setTimeout(resolve, 10000))
currentOperation = await getAI().operations.getVideosOperation({ operation: currentOperation })
}
const videoUri = currentOperation.response?.generatedVideos?.[0]?.video?.uri
if (!videoUri) throw new Error('No video generated')
const response = await fetch(`${videoUri}&key=${process.env['GEMINI_API_KEY']}`)
const arrayBuffer = await response.arrayBuffer()
const videoBuffer = Buffer.from(arrayBuffer)
const videoPath = `${trainerId}/${workoutId}.mp4`
const { error } = await supabase.storage
.from('trainers-videos')
.upload(videoPath, videoBuffer, {
contentType: 'video/mp4',
upsert: true,
})
if (error) throw new Error(`Failed to upload video: ${error.message}`)
const { data: { publicUrl } } = supabase.storage
.from('trainers-videos')
.getPublicUrl(videoPath)
return { workoutId, trainerId, videoUrl: publicUrl, videoPath }
}
export async function isWorkoutInProgram(_workoutId: string): Promise<boolean> {
// Legacy program_workouts table removed — workout programs now use program_tabatas
return false
}
export async function isVideoGeneratedForWorkout(_workoutId: string): Promise<boolean> {
// Legacy program_workouts table removed — video status tracked in workouts table
return false
}
export async function getWorkoutVideoStatus(workoutId: string): Promise<{
hasVideo: boolean
videoUrl: string | null
inProgram: boolean
videoGenerated: boolean
}> {
const workoutResult = await supabase
.from('workouts')
.select('video_url')
.eq('id', workoutId)
.single()
const workoutVideoUrl = (workoutResult.data as WorkoutRow | null)?.video_url ?? null
return {
hasVideo: !!workoutVideoUrl,
videoUrl: workoutVideoUrl,
inProgram: false,
videoGenerated: false,
}
}

View File

@@ -150,6 +150,194 @@ export interface Database {
sort_order: number
}
}
workout_programs: {
Row: {
id: string
title: string
description: string
body_zone: 'upper-body' | 'lower-body' | 'full-body'
level: 'Beginner' | 'Intermediate' | 'Advanced'
is_free: boolean
estimated_duration: number
estimated_calories: number
icon: string | null
accent_color: string | null
sort_order: number
created_at: string
updated_at: string
}
Insert: {
id?: string
title: string
description?: string
body_zone: 'upper-body' | 'lower-body' | 'full-body'
level: 'Beginner' | 'Intermediate' | 'Advanced'
is_free?: boolean
estimated_duration?: number
estimated_calories?: number
icon?: string | null
accent_color?: string | null
sort_order?: number
}
Update: Partial<Omit<Database['public']['Tables']['workout_programs']['Insert'], 'id'>>
}
program_tabatas: {
Row: {
id: string
program_id: string
position: number
exercise_1_name: string
exercise_1_name_en: string | null
exercise_1_tip: string | null
exercise_1_tip_en: string | null
exercise_1_modification: string | null
exercise_1_modification_en: string | null
exercise_1_progression: string | null
exercise_1_progression_en: string | null
exercise_2_name: string
exercise_2_name_en: string | null
exercise_2_tip: string | null
exercise_2_tip_en: string | null
exercise_2_modification: string | null
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
}
Insert: {
id?: string
program_id: string
position: number
exercise_1_name: string
exercise_1_name_en?: string | null
exercise_1_tip?: string | null
exercise_1_tip_en?: string | null
exercise_1_modification?: string | null
exercise_1_modification_en?: string | null
exercise_1_progression?: string | null
exercise_1_progression_en?: string | null
exercise_2_name: string
exercise_2_name_en?: string | null
exercise_2_tip?: string | null
exercise_2_tip_en?: string | null
exercise_2_modification?: string | null
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<Omit<Database['public']['Tables']['program_tabatas']['Insert'], 'id' | 'program_id'>>
}
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<Omit<Database['public']['Tables']['workout_warmup_exercises']['Insert'], 'id' | 'program_id'>>
}
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<Omit<Database['public']['Tables']['workout_stretch_exercises']['Insert'], 'id' | 'program_id'>>
}
workout_videos: {
Row: {
id: string
workout_id: string
trainer_id: string
video_url: string
video_path: string
created_at: string
}
Insert: {
id?: string
workout_id: string
trainer_id: string
video_url: string
video_path: string
}
Update: {
video_url?: string
video_path?: string
}
}
exercise_videos: {
Row: {
id: string
workout_id: string
trainer_id: string
exercise_name: string
video_url: string
video_path: string
video_type: 'exercise' | 'rest'
duration_seconds: number | null
created_at: string
}
Insert: {
id?: string
workout_id: string
trainer_id: string
exercise_name: string
video_url: string
video_path: string
video_type?: 'exercise' | 'rest'
duration_seconds?: number | null
}
Update: {
video_url?: string
video_path?: string
video_type?: 'exercise' | 'rest'
duration_seconds?: number | null
}
}
admin_users: {
Row: {
id: string

View File

@@ -0,0 +1,80 @@
-- Migration Script: Reset trainers to Félia and Félix only
-- Run this in your Supabase SQL Editor
DO $$
DECLARE
felia_id UUID;
felix_id UUID;
old_trainer_ids UUID[];
BEGIN
-- Step 1: Get IDs of trainers to be replaced
SELECT ARRAY[
(SELECT id FROM trainers WHERE name = 'Emma'),
(SELECT id FROM trainers WHERE name = 'Jake'),
(SELECT id FROM trainers WHERE name = 'Mia'),
(SELECT id FROM trainers WHERE name = 'Alex'),
(SELECT id FROM trainers WHERE name = 'Sofia')
] INTO old_trainer_ids;
-- Step 2: Create Félia if not exists
SELECT COALESCE(
(SELECT id FROM trainers WHERE name = 'Félia' LIMIT 1),
gen_random_uuid()
) INTO felia_id;
INSERT INTO trainers (id, name, specialty, color, workout_count, avatar_url)
VALUES (felia_id, 'Félia', 'Core', '#30D158', 0, NULL)
ON CONFLICT (id) DO UPDATE SET
name = 'Félia',
specialty = 'Core',
color = '#30D158';
-- Step 3: Create Félix if not exists
SELECT COALESCE(
(SELECT id FROM trainers WHERE name = 'Félix' LIMIT 1),
gen_random_uuid()
) INTO felix_id;
INSERT INTO trainers (id, name, specialty, color, workout_count, avatar_url)
VALUES (felix_id, 'Félix', 'Strength', '#FFD60A', 0, NULL)
ON CONFLICT (id) DO UPDATE SET
name = 'Félix',
specialty = 'Strength',
color = '#FFD60A';
-- Step 4: Get the final trainer IDs
SELECT id INTO felia_id FROM trainers WHERE name = 'Félia';
SELECT id INTO felix_id FROM trainers WHERE name = 'Félix';
-- Step 5: Update workouts - female trainers → felia, male trainers → felix
UPDATE workouts
SET trainer_id = felia_id
WHERE trainer_id IN (
SELECT id FROM trainers WHERE name IN ('Emma', 'Mia', 'Sofia')
);
UPDATE workouts
SET trainer_id = felix_id
WHERE trainer_id IN (
SELECT id FROM trainers WHERE name IN ('Jake', 'Alex')
);
-- Step 6: Update workout counts
UPDATE trainers SET workout_count = (
SELECT COUNT(*) FROM workouts WHERE trainer_id = felia_id
) WHERE id = felia_id;
UPDATE trainers SET workout_count = (
SELECT COUNT(*) FROM workouts WHERE trainer_id = felix_id
) WHERE id = felix_id;
-- Step 7: Delete old trainers
DELETE FROM trainers WHERE name IN ('Emma', 'Jake', 'Mia', 'Alex', 'Sofia');
END $$;
-- Step 8: Verify
SELECT 'Trainers:' AS result;
SELECT id::text, name, specialty, color, workout_count FROM trainers ORDER BY name;
SELECT 'Workout trainer distribution:' AS result;
SELECT trainer_id::text, COUNT(*) as workout_count FROM workouts GROUP BY trainer_id;

View File

@@ -0,0 +1,60 @@
-- Storage RLS Policies for Trainer Assets
-- Run this in your Supabase SQL Editor
-- Enable RLS on storage buckets (if not already enabled)
ALTER TABLE storage.buckets ENABLE ROW LEVEL SECURITY;
-- Trainer Avatars Bucket Policies
DROP POLICY IF EXISTS "Public read access" ON storage.objects;
DROP POLICY IF EXISTS "Public upload access" ON storage.objects;
DROP POLICY IF EXISTS "Public update access" ON storage.objects;
DROP POLICY IF EXISTS "Public delete access" ON storage.objects;
-- Read access (anyone can view)
CREATE POLICY "Public read access" ON storage.objects
FOR SELECT USING (bucket_id = 'trainer-avatars');
-- Upload access (anyone can upload)
CREATE POLICY "Public upload access" ON storage.objects
FOR INSERT WITH CHECK (bucket_id = 'trainer-avatars');
-- Update access (anyone can update)
CREATE POLICY "Public update access" ON storage.objects
FOR UPDATE USING (bucket_id = 'trainer-avatars');
-- Delete access (anyone can delete)
CREATE POLICY "Public delete access" ON storage.objects
FOR DELETE USING (bucket_id = 'trainer-avatars');
-- Trainer Videos Bucket Policies
DROP POLICY IF EXISTS "Public read access videos" ON storage.objects;
DROP POLICY IF EXISTS "Public upload access videos" ON storage.objects;
DROP POLICY IF EXISTS "Public update access videos" ON storage.objects;
DROP POLICY IF EXISTS "Public delete access videos" ON storage.objects;
-- Read access (anyone can view)
CREATE POLICY "Public read access videos" ON storage.objects
FOR SELECT USING (bucket_id = 'trainer-videos');
-- Upload access (anyone can upload)
CREATE POLICY "Public upload access videos" ON storage.objects
FOR INSERT WITH CHECK (bucket_id = 'trainer-videos');
-- Update access (anyone can update)
CREATE POLICY "Public update access videos" ON storage.objects
FOR UPDATE USING (bucket_id = 'trainer-videos');
-- Delete access (anyone can delete)
CREATE POLICY "Public delete access videos" ON storage.objects
FOR DELETE USING (bucket_id = 'trainer-videos');
-- Exercise Videos Table Policies (if table exists)
DROP POLICY IF EXISTS "Public read access exercise_videos" ON exercise_videos;
DROP POLICY IF EXISTS "Public insert access exercise_videos" ON exercise_videos;
DROP POLICY IF EXISTS "Public update access exercise_videos" ON exercise_videos;
DROP POLICY IF EXISTS "Public delete access exercise_videos" ON exercise_videos;
CREATE POLICY "Public read access exercise_videos" ON exercise_videos FOR SELECT USING (true);
CREATE POLICY "Public insert access exercise_videos" ON exercise_videos FOR INSERT WITH CHECK (true);
CREATE POLICY "Public update access exercise_videos" ON exercise_videos FOR UPDATE USING (true);
CREATE POLICY "Public delete access exercise_videos" ON exercise_videos FOR DELETE USING (true);

View File

@@ -0,0 +1,24 @@
-- Create exercise_videos table
CREATE TABLE IF NOT EXISTS exercise_videos (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
workout_id UUID NOT NULL,
trainer_id UUID NOT NULL,
exercise_name TEXT NOT NULL,
video_url TEXT NOT NULL,
video_path TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Enable RLS
ALTER TABLE exercise_videos ENABLE ROW LEVEL SECURITY;
-- Public policies
DROP POLICY IF EXISTS "Public read access exercise_videos" ON exercise_videos;
DROP POLICY IF EXISTS "Public insert access exercise_videos" ON exercise_videos;
DROP POLICY IF EXISTS "Public update access exercise_videos" ON exercise_videos;
DROP POLICY IF EXISTS "Public delete access exercise_videos" ON exercise_videos;
CREATE POLICY "Public read access exercise_videos" ON exercise_videos FOR SELECT USING (true);
CREATE POLICY "Public insert access exercise_videos" ON exercise_videos FOR INSERT WITH CHECK (true);
CREATE POLICY "Public update access exercise_videos" ON exercise_videos FOR UPDATE USING (true);
CREATE POLICY "Public delete access exercise_videos" ON exercise_videos FOR DELETE USING (true);

View File

@@ -0,0 +1,11 @@
-- Add video_type and duration_seconds columns to exercise_videos table
ALTER TABLE exercise_videos
ADD COLUMN IF NOT EXISTS video_type TEXT DEFAULT 'exercise'
CHECK (video_type IN ('exercise', 'rest'));
ALTER TABLE exercise_videos
ADD COLUMN IF NOT EXISTS duration_seconds INTEGER;
-- Update existing rows to have appropriate values
UPDATE exercise_videos SET video_type = 'exercise' WHERE video_type IS NULL;
UPDATE exercise_videos SET duration_seconds = 8 WHERE duration_seconds IS NULL;

View File

@@ -0,0 +1,127 @@
-- Tabata Kine Programs Schema
-- Migration 005: New kiné program system
-- Replaces the old 3-program model with 4 kiné programs
-- ─── Kine Programs ──────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.kine_programs (
id TEXT PRIMARY KEY, -- 'debutant', 'intermediaire', 'avance', 'bureau'
title TEXT NOT NULL,
title_en TEXT NOT NULL,
description TEXT NOT NULL,
description_en TEXT NOT NULL,
tier TEXT NOT NULL CHECK (tier IN ('free', 'premium')),
accent_color TEXT NOT NULL DEFAULT '#30D158',
icon TEXT NOT NULL DEFAULT 'seedling',
duration_weeks INTEGER NOT NULL DEFAULT 4,
sessions_per_week INTEGER NOT NULL,
total_sessions INTEGER NOT NULL,
equipment JSONB NOT NULL DEFAULT '{"required": [], "optional": []}',
focus_areas TEXT[] DEFAULT '{}',
focus_areas_en TEXT[] DEFAULT '{}',
principles TEXT[] DEFAULT '{}',
principles_en TEXT[] DEFAULT '{}',
completion_criteria TEXT[] DEFAULT '{}',
completion_criteria_en TEXT[] DEFAULT '{}',
next_program_id TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ─── Kine Sessions ──────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.kine_sessions (
id TEXT PRIMARY KEY, -- 'deb-w1-s1', 'int-w2-s3', etc.
program_id TEXT NOT NULL REFERENCES public.kine_programs(id) ON DELETE CASCADE,
week_number INTEGER NOT NULL CHECK (week_number >= 1 AND week_number <= 6),
day_number INTEGER NOT NULL CHECK (day_number >= 1 AND day_number <= 7),
title TEXT NOT NULL,
title_en TEXT NOT NULL,
description TEXT,
description_en TEXT,
focus TEXT[] DEFAULT '{}',
focus_en TEXT[] DEFAULT '{}',
warmup JSONB NOT NULL, -- WarmupPhase structure
blocks JSONB NOT NULL, -- TabataBlock[] array
cooldown JSONB NOT NULL, -- CooldownPhase structure
equipment TEXT[] DEFAULT '{}',
total_rounds INTEGER NOT NULL,
total_duration INTEGER NOT NULL, -- minutes
calories INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ─── Kine Exercises Library ─────────────────────────────────
-- Track individual exercises for video generation
CREATE TABLE IF NOT EXISTS public.kine_exercises (
id TEXT PRIMARY KEY, -- slug: 'squat-classique', 'pont-fessier'
name_fr TEXT NOT NULL,
name_en TEXT NOT NULL,
conseil_fr TEXT,
conseil_en TEXT,
modification TEXT,
modification_en TEXT,
progression TEXT,
progression_en TEXT,
video_url TEXT,
video_generated BOOLEAN DEFAULT FALSE,
video_generation_status TEXT CHECK (video_generation_status IN ('pending', 'generating', 'completed', 'failed')),
video_generation_job_id TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ─── Kine Weeks ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.kine_weeks (
id TEXT PRIMARY KEY, -- 'deb-w1', 'int-w2', etc.
program_id TEXT NOT NULL REFERENCES public.kine_programs(id) ON DELETE CASCADE,
week_number INTEGER NOT NULL,
title TEXT NOT NULL,
title_en TEXT NOT NULL,
description TEXT,
description_en TEXT,
focus TEXT,
focus_en TEXT,
is_deload BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ─── Indexes ────────────────────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_kine_sessions_program ON public.kine_sessions(program_id);
CREATE INDEX IF NOT EXISTS idx_kine_sessions_week ON public.kine_sessions(program_id, week_number);
CREATE INDEX IF NOT EXISTS idx_kine_weeks_program ON public.kine_weeks(program_id);
CREATE INDEX IF NOT EXISTS idx_kine_exercises_generated ON public.kine_exercises(video_generated);
-- ─── Row Level Security ─────────────────────────────────────
ALTER TABLE public.kine_programs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.kine_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.kine_exercises ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.kine_weeks ENABLE ROW LEVEL SECURITY;
-- Public read access
CREATE POLICY "Public read kine_programs" ON public.kine_programs FOR SELECT USING (true);
CREATE POLICY "Public read kine_sessions" ON public.kine_sessions FOR SELECT USING (true);
CREATE POLICY "Public read kine_exercises" ON public.kine_exercises FOR SELECT USING (true);
CREATE POLICY "Public read kine_weeks" ON public.kine_weeks FOR SELECT USING (true);
-- ─── Seed: Debutant Program ─────────────────────────────────
INSERT INTO public.kine_programs (id, title, title_en, description, description_en, tier, accent_color, icon, duration_weeks, sessions_per_week, total_sessions) VALUES
('debutant', 'Débutant', 'Beginner',
'Apprendre le protocole tabata, construire les bases techniques de chaque mouvement fondamental.',
'Learn the tabata protocol, build technical foundations for each fundamental movement.',
'free', '#30D158', 'seedling', 4, 3, 12)
ON CONFLICT (id) DO NOTHING;
-- Seed weeks
INSERT INTO public.kine_weeks (id, program_id, week_number, title, title_en, description, description_en, focus, focus_en, is_deload) VALUES
('deb-w1', 'debutant', 1, 'Découverte du rythme', 'Finding Your Rhythm', 'Un bloc tabata par séance (4 min) + échauffement + retour au calme.', 'One tabata block per session + warmup + cooldown.', 'Apprentissage du protocole 20/10', 'Learning the 20/10 protocol', false),
('deb-w2', 'debutant', 2, 'Consolidation', 'Building Strength', '2 blocs tabata + 1 min récup entre les blocs.', '2 tabata blocks + 1 min recovery between blocks.', 'Consolidation des mouvements', 'Consolidating movements', false),
('deb-w3', 'debutant', 3, 'Montée en intensité', 'Building Intensity', '3 blocs tabata + 1 min récupération entre chaque.', '3 tabata blocks + 1 min recovery between each.', 'Impacts très légers, volume augmenté', 'Very light impact, increased volume', false),
('deb-w4', 'debutant', 4, 'Décharge & consolidation', 'Deload & Consolidation', 'Retour à 2 blocs. Volume réduit de 40%.', 'Back to 2 blocks. Volume reduced by 40%.', 'Technique parfaite, respiration', 'Perfect technique, breathing', true)
ON CONFLICT (id) DO NOTHING;

View File

@@ -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');

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@
"test:all": "npm run test && npm run test:e2e"
},
"dependencies": {
"@google/genai": "^1.47.0",
"@supabase/ssr": "^0.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -1,64 +0,0 @@
{
"expo": {
"name": "TabataFit",
"slug": "tabatafit",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "tabatafit",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.millianlmx.tabatafit",
"buildNumber": "1",
"infoPlist": {
"NSHealthShareUsageDescription": "TabataFit uses HealthKit to read and write workout data including heart rate, calories burned, and exercise minutes.",
"NSHealthUpdateUsageDescription": "TabataFit saves your workout sessions to Apple Health so you can track your fitness progress.",
"NSCameraUsageDescription": "TabataFit uses the camera for profile photos and workout form checks.",
"NSUserTrackingUsageDescription": "TabataFit uses this to provide personalized workout recommendations.",
"ITSAppUsesNonExemptEncryption": false
},
"config": {
"usesNonExemptEncryption": false
}
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"package": "com.millianlmx.tabatafit"
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
}
}
],
"expo-video",
"expo-localization",
"./plugins/withStoreKitConfig"
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
}
}
}

View File

@@ -1,33 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Feb 20, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5056 | 8:24 AM | ✅ | Completed Host wrapper restoration in home screen | ~258 |
| #5055 | " | ✅ | Re-added Host wrapper to home screen JSX | ~187 |
| #5054 | " | ✅ | Re-added Host import to home screen | ~184 |
| #5043 | 8:22 AM | ✅ | Removed closing Host tag from profile screen | ~210 |
| #5042 | " | ✅ | Removed opening Host tag from profile screen | ~164 |
| #5041 | " | ✅ | Removed closing Host tag from browse screen | ~187 |
| #5040 | " | ✅ | Removed opening Host tag from browse screen | ~159 |
| #5039 | 8:21 AM | ✅ | Removed closing Host tag from activity screen | ~193 |
| #5038 | " | ✅ | Removed opening Host tag from activity screen | ~154 |
| #5037 | " | ✅ | Removed closing Host tag from workouts screen | ~195 |
| #5036 | " | ✅ | Removed opening Host tag from workouts screen | ~164 |
| #5035 | " | ✅ | Removed closing Host tag from home screen JSX | ~197 |
| #5034 | " | ✅ | Removed Host wrapper from home screen JSX | ~139 |
| #5031 | 8:19 AM | ✅ | Removed Host import from profile screen | ~184 |
| #5030 | " | ✅ | Removed Host import from browse screen | ~190 |
| #5029 | 8:18 AM | ✅ | Removed Host import from activity screen | ~183 |
| #5028 | " | ✅ | Removed Host import from workouts screen | ~189 |
| #5027 | " | ✅ | Removed Host import from home screen index.tsx | ~180 |
| #5024 | " | 🔵 | Activity screen properly wraps content with Host component | ~237 |
| #5023 | " | 🔵 | Profile screen properly wraps content with Host component | ~246 |
| #5022 | 8:14 AM | 🔵 | Browse screen properly wraps content with Host component | ~217 |
| #5021 | " | 🔵 | Workouts screen properly wraps content with Host component | ~228 |
| #5020 | 8:13 AM | 🔵 | Home screen properly wraps content with Host component | ~238 |
</claude-mem-context>

View File

@@ -1,47 +0,0 @@
/**
* TabataFit Tab Layout
* Native iOS tabs with liquid glass effect
* 4 tabs: Home, Workouts, Activity, Profile
* Redirects to onboarding if not completed
*/
import { Redirect } from 'expo-router'
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs'
import { useTranslation } from 'react-i18next'
import { BRAND } from '@/src/shared/constants/colors'
import { useUserStore } from '@/src/shared/stores'
export default function TabLayout() {
const { t } = useTranslation('screens')
const onboardingCompleted = useUserStore((s) => s.profile.onboardingCompleted)
if (!onboardingCompleted) {
return <Redirect href="/onboarding" />
}
return (
<NativeTabs
tintColor={BRAND.PRIMARY}
>
<NativeTabs.Trigger name="index">
<Icon sf={{ default: 'house', selected: 'house.fill' }} />
<Label>{t('tabs.home')}</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="explore">
<Icon sf={{ default: 'flame', selected: 'flame.fill' }} />
<Label>{t('tabs.explore')}</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="activity">
<Icon sf={{ default: 'chart.bar', selected: 'chart.bar.fill' }} />
<Label>{t('tabs.activity')}</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="profile">
<Icon sf={{ default: 'person', selected: 'person.fill' }} />
<Label>{t('tabs.profile')}</Label>
</NativeTabs.Trigger>
</NativeTabs>
)
}

View File

@@ -1,628 +0,0 @@
/**
* TabataFit Activity Screen
* Premium stats dashboard — streak, rings, weekly chart, history
*/
import { View, StyleSheet, ScrollView, Dimensions, Pressable } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { LinearGradient } from 'expo-linear-gradient'
import { BlurView } from 'expo-blur'
import { Icon, type IconName } from '@/src/shared/components/Icon'
import Svg, { Circle } from 'react-native-svg'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useActivityStore, getWeeklyActivity } from '@/src/shared/stores'
import { getWorkoutById } from '@/src/shared/data'
import { ACHIEVEMENTS } from '@/src/shared/data'
import { StyledText } from '@/src/shared/components/StyledText'
import { useThemeColors, BRAND, PHASE, GRADIENTS } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
const { width: SCREEN_WIDTH } = Dimensions.get('window')
// ═══════════════════════════════════════════════════════════════════════════
// STAT RING — Custom circular progress (pure RN, no SwiftUI)
// ═══════════════════════════════════════════════════════════════════════════
function StatRing({
value,
max,
color,
size = 64,
}: {
value: number
max: number
color: string
size?: number
}) {
const colors = useThemeColors()
const strokeWidth = 5
const radius = (size - strokeWidth) / 2
const circumference = 2 * Math.PI * radius
const progress = Math.min(value / max, 1)
const strokeDashoffset = circumference * (1 - progress)
return (
<Svg width={size} height={size}>
{/* Track */}
<Circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={colors.bg.overlay2}
strokeWidth={strokeWidth}
fill="none"
/>
{/* Progress */}
<Circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={color}
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
opacity={progress > 0 ? 1 : 0.3}
/>
</Svg>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STAT CARD
// ═══════════════════════════════════════════════════════════════════════════
function StatCard({
label,
value,
max,
color,
icon,
}: {
label: string
value: number
max: number
color: string
icon: IconName
}) {
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
return (
<View style={styles.statCard}>
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<View style={styles.statCardInner}>
<StatRing value={value} max={max} color={color} size={52} />
<View style={{ flex: 1, marginLeft: SPACING[3] }}>
<StyledText size={24} weight="bold" color={colors.text.primary}>
{String(value)}
</StyledText>
<StyledText size={12} color={colors.text.tertiary}>
{label}
</StyledText>
</View>
<Icon name={icon} size={18} tintColor={color} />
</View>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// WEEKLY BAR
// ═══════════════════════════════════════════════════════════════════════════
function WeeklyBar({
day,
completed,
isToday,
}: {
day: string
completed: boolean
isToday: boolean
}) {
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
return (
<View style={styles.weekBarColumn}>
<View style={[styles.weekBar, completed && styles.weekBarFilled]}>
{completed && (
<LinearGradient
colors={[BRAND.PRIMARY, BRAND.PRIMARY_LIGHT]}
style={[StyleSheet.absoluteFill, { borderRadius: 4 }]}
/>
)}
</View>
<StyledText
size={11}
weight={isToday ? 'bold' : 'regular'}
color={isToday ? colors.text.primary : colors.text.hint}
>
{day}
</StyledText>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// EMPTY STATE
// ═══════════════════════════════════════════════════════════════════════════
function EmptyState({ onStartWorkout }: { onStartWorkout: () => void }) {
const { t } = useTranslation()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
return (
<View style={styles.emptyState}>
<View style={styles.emptyIconCircle}>
<Icon name="flame" size={48} tintColor={BRAND.PRIMARY} />
</View>
<StyledText size={22} weight="bold" color={colors.text.primary} style={styles.emptyTitle}>
{t('screens:activity.emptyTitle')}
</StyledText>
<StyledText size={15} color={colors.text.tertiary} style={styles.emptySubtitle}>
{t('screens:activity.emptySubtitle')}
</StyledText>
<Pressable style={styles.emptyCtaButton} onPress={onStartWorkout}>
<LinearGradient
colors={GRADIENTS.CTA}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<Icon name="play.fill" size={18} tintColor="#FFFFFF" style={{ marginRight: SPACING[2] }} />
<StyledText size={16} weight="semibold" color="#FFFFFF">
{t('screens:activity.startFirstWorkout')}
</StyledText>
</Pressable>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN SCREEN
// ═══════════════════════════════════════════════════════════════════════════
const DAY_KEYS = ['days.sun', 'days.mon', 'days.tue', 'days.wed', 'days.thu', 'days.fri', 'days.sat'] as const
export default function ActivityScreen() {
const { t } = useTranslation()
const insets = useSafeAreaInsets()
const router = useRouter()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const streak = useActivityStore((s) => s.streak)
const history = useActivityStore((s) => s.history)
const totalWorkouts = history.length
const totalMinutes = useMemo(() => history.reduce((sum, r) => sum + r.durationMinutes, 0), [history])
const totalCalories = useMemo(() => history.reduce((sum, r) => sum + r.calories, 0), [history])
const recentWorkouts = useMemo(() => history.slice(0, 5), [history])
const weeklyActivity = useMemo(() => getWeeklyActivity(history), [history])
const today = new Date().getDay() // 0=Sun
// Check achievements
const unlockedAchievements = ACHIEVEMENTS.filter(a => {
switch (a.type) {
case 'workouts': return totalWorkouts >= a.requirement
case 'streak': return streak.longest >= a.requirement
case 'minutes': return totalMinutes >= a.requirement
case 'calories': return totalCalories >= a.requirement
default: return false
}
})
const displayAchievements = ACHIEVEMENTS.slice(0, 4).map(a => ({
...a,
unlocked: unlockedAchievements.some(u => u.id === a.id),
}))
const formatDate = (timestamp: number) => {
const now = Date.now()
const diff = now - timestamp
if (diff < 86400000) return t('screens:activity.today')
if (diff < 172800000) return t('screens:activity.yesterday')
return t('screens:activity.daysAgo', { count: Math.floor(diff / 86400000) })
}
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<StyledText
size={34}
weight="bold"
color={colors.text.primary}
style={{ marginBottom: SPACING[6] }}
>
{t('screens:activity.title')}
</StyledText>
{/* Empty state when no history */}
{history.length === 0 ? (
<EmptyState onStartWorkout={() => router.push('/(tabs)/explore' as any)} />
) : (
<>
{/* Streak Banner */}
<View style={styles.streakBanner}>
<LinearGradient
colors={GRADIENTS.CTA}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<View style={styles.streakRow}>
<View style={styles.streakIconWrap}>
<Icon name="flame.fill" size={28} tintColor="#FFFFFF" />
</View>
<View style={{ flex: 1 }}>
<StyledText size={28} weight="bold" color="#FFFFFF">
{String(streak.current || 0)}
</StyledText>
<StyledText size={13} color="rgba(255,255,255,0.8)">
{t('screens:activity.dayStreak')}
</StyledText>
</View>
<View style={styles.streakMeta}>
<StyledText size={11} color="rgba(255,255,255,0.6)">
{t('screens:activity.longest')}
</StyledText>
<StyledText size={20} weight="bold" color="#FFFFFF">
{String(streak.longest)}
</StyledText>
</View>
</View>
</View>
{/* Stats Grid — 2x2 */}
<View style={styles.statsGrid}>
<StatCard
label={t('screens:activity.workouts')}
value={totalWorkouts}
max={100}
color={BRAND.PRIMARY}
icon="dumbbell"
/>
<StatCard
label={t('screens:activity.minutes')}
value={totalMinutes}
max={300}
color={PHASE.REST}
icon="clock"
/>
<StatCard
label={t('screens:activity.calories')}
value={totalCalories}
max={5000}
color={BRAND.SECONDARY}
icon="bolt"
/>
<StatCard
label={t('screens:activity.bestStreak')}
value={streak.longest}
max={30}
color={BRAND.SUCCESS}
icon="arrow.up.right"
/>
</View>
{/* This Week */}
<View style={styles.section}>
<StyledText size={20} weight="semibold" color={colors.text.primary} style={{ marginBottom: SPACING[4] }}>
{t('screens:activity.thisWeek')}
</StyledText>
<View style={styles.weekCard}>
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<View style={styles.weekBarsRow}>
{weeklyActivity.map((d, i) => (
<WeeklyBar
key={i}
day={t(DAY_KEYS[i])}
completed={d.completed}
isToday={i === today}
/>
))}
</View>
<View style={styles.weekSummary}>
<StyledText size={13} color={colors.text.tertiary}>
{t('screens:activity.ofDays', { completed: weeklyActivity.filter(d => d.completed).length })}
</StyledText>
</View>
</View>
</View>
{/* Recent Workouts */}
{recentWorkouts.length > 0 && (
<View style={styles.section}>
<StyledText size={20} weight="semibold" color={colors.text.primary} style={{ marginBottom: SPACING[4] }}>
{t('screens:activity.recent')}
</StyledText>
<View style={styles.recentCard}>
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
{recentWorkouts.map((result, idx) => {
const workout = getWorkoutById(result.workoutId)
const workoutTitle = workout ? t(`content:workouts.${workout.id}`, { defaultValue: workout.title }) : t('screens:activity.workouts')
return (
<View key={result.id}>
<View style={styles.recentRow}>
<View style={styles.recentDot}>
<View style={[styles.dot, { backgroundColor: BRAND.PRIMARY }]} />
</View>
<View style={{ flex: 1 }}>
<StyledText size={15} weight="semibold" color={colors.text.primary}>
{workoutTitle}
</StyledText>
<StyledText size={12} color={colors.text.tertiary}>
{formatDate(result.completedAt) + ' \u00B7 ' + t('units.minUnit', { count: result.durationMinutes })}
</StyledText>
</View>
<StyledText size={14} weight="semibold" color={BRAND.PRIMARY}>
{t('units.calUnit', { count: result.calories })}
</StyledText>
</View>
{idx < recentWorkouts.length - 1 && <View style={styles.recentDivider} />}
</View>
)
})}
</View>
</View>
)}
{/* Achievements */}
<View style={styles.section}>
<StyledText size={20} weight="semibold" color={colors.text.primary} style={{ marginBottom: SPACING[4] }}>
{t('screens:activity.achievements')}
</StyledText>
<View style={styles.achievementsRow}>
{displayAchievements.map((a) => (
<View key={a.id} style={styles.achievementCard}>
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<View
style={[
styles.achievementIcon,
a.unlocked
? { backgroundColor: 'rgba(255, 107, 53, 0.15)' }
: { backgroundColor: 'rgba(255, 255, 255, 0.04)' },
]}
>
<Icon
name={a.unlocked ? 'trophy.fill' : 'lock.fill'}
size={22}
tintColor={a.unlocked ? BRAND.PRIMARY : colors.text.hint}
/>
</View>
<StyledText
size={11}
weight="semibold"
color={a.unlocked ? colors.text.primary : colors.text.hint}
numberOfLines={1}
style={{ marginTop: SPACING[2], textAlign: 'center' }}
>
{t(`content:achievements.${a.id}.title`, { defaultValue: a.title })}
</StyledText>
</View>
))}
</View>
</View>
</>
)}
</ScrollView>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
const CARD_HALF = (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[3]) / 2
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg.base,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
},
// Streak
streakBanner: {
borderRadius: RADIUS.GLASS_CARD,
overflow: 'hidden',
marginBottom: SPACING[5],
...colors.shadow.md,
},
streakRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: SPACING[5],
paddingVertical: SPACING[5],
gap: SPACING[4],
},
streakIconWrap: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: 'rgba(255,255,255,0.15)',
alignItems: 'center',
justifyContent: 'center',
},
streakMeta: {
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.1)',
paddingHorizontal: SPACING[4],
paddingVertical: SPACING[2],
borderRadius: RADIUS.MD,
},
// Stats 2x2
statsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: SPACING[3],
marginBottom: SPACING[6],
},
statCard: {
width: CARD_HALF,
borderRadius: RADIUS.LG,
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.bg.overlay2,
},
statCardInner: {
flexDirection: 'row',
alignItems: 'center',
padding: SPACING[4],
},
// Section
section: {
marginBottom: SPACING[6],
},
// Weekly
weekCard: {
borderRadius: RADIUS.LG,
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.bg.overlay2,
paddingTop: SPACING[5],
paddingBottom: SPACING[4],
},
weekBarsRow: {
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'flex-end',
paddingHorizontal: SPACING[4],
height: 100,
},
weekBarColumn: {
alignItems: 'center',
flex: 1,
gap: SPACING[2],
},
weekBar: {
width: 24,
height: 60,
borderRadius: 4,
backgroundColor: colors.border.glassLight,
overflow: 'hidden',
},
weekBarFilled: {
backgroundColor: BRAND.PRIMARY,
},
weekSummary: {
alignItems: 'center',
marginTop: SPACING[3],
paddingTop: SPACING[3],
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.bg.overlay2,
marginHorizontal: SPACING[4],
},
// Recent
recentCard: {
borderRadius: RADIUS.LG,
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.bg.overlay2,
paddingVertical: SPACING[2],
},
recentRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: SPACING[4],
paddingVertical: SPACING[3],
},
recentDot: {
width: 24,
alignItems: 'center',
marginRight: SPACING[3],
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
},
recentDivider: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.border.glassLight,
marginLeft: SPACING[4] + 24 + SPACING[3],
},
// Achievements
achievementsRow: {
flexDirection: 'row',
gap: SPACING[3],
},
achievementCard: {
flex: 1,
aspectRatio: 0.9,
borderRadius: RADIUS.LG,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: colors.bg.overlay2,
overflow: 'hidden',
paddingHorizontal: SPACING[1],
},
achievementIcon: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
},
// Empty State
emptyState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingTop: SPACING[10],
paddingHorizontal: SPACING[6],
},
emptyIconCircle: {
width: 96,
height: 96,
borderRadius: 48,
backgroundColor: `${BRAND.PRIMARY}15`,
alignItems: 'center',
justifyContent: 'center',
marginBottom: SPACING[6],
},
emptyTitle: {
textAlign: 'center' as const,
marginBottom: SPACING[2],
},
emptySubtitle: {
textAlign: 'center' as const,
lineHeight: 22,
marginBottom: SPACING[8],
},
emptyCtaButton: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
justifyContent: 'center' as const,
height: 52,
paddingHorizontal: SPACING[8],
borderRadius: RADIUS.LG,
overflow: 'hidden' as const,
},
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,735 +0,0 @@
/**
* TabataFit Home Screen - 3 Program Design
* Premium Apple Fitness+ inspired layout
*/
import { View, StyleSheet, ScrollView, Pressable, Animated } from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import { BlurView } from 'expo-blur'
import { Icon, type IconName } from '@/src/shared/components/Icon'
import { useMemo, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
import { useUserStore, useProgramStore, useActivityStore, getWeeklyActivity } from '@/src/shared/stores'
import { PROGRAMS, ASSESSMENT_WORKOUT } from '@/src/shared/data/programs'
import { StyledText } from '@/src/shared/components/StyledText'
import { useThemeColors, BRAND } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import type { ProgramId } from '@/src/shared/types'
// Feature flags — disable incomplete features
const FEATURE_FLAGS = {
ASSESSMENT_ENABLED: false, // Assessment player not yet implemented
}
const FONTS = {
LARGE_TITLE: 34,
TITLE: 28,
TITLE_2: 22,
HEADLINE: 17,
BODY: 16,
CAPTION: 13,
}
// Program metadata for display
const PROGRAM_META: Record<ProgramId, { icon: IconName; gradient: [string, string]; accent: string }> = {
'upper-body': {
icon: 'dumbbell',
gradient: ['#FF6B35', '#FF3B30'],
accent: '#FF6B35',
},
'lower-body': {
icon: 'figure.walk',
gradient: ['#30D158', '#28A745'],
accent: '#30D158',
},
'full-body': {
icon: 'flame',
gradient: ['#5AC8FA', '#007AFF'],
accent: '#5AC8FA',
},
}
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
// ═══════════════════════════════════════════════════════════════════════════
// PROGRAM CARD
// ═══════════════════════════════════════════════════════════════════════════
function ProgramCard({
programId,
onPress,
}: {
programId: ProgramId
onPress: () => void
}) {
const { t } = useTranslation('screens')
const haptics = useHaptics()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const program = PROGRAMS[programId]
const meta = PROGRAM_META[programId]
const programStatus = useProgramStore((s) => s.getProgramStatus(programId))
const completion = useProgramStore((s) => s.getProgramCompletion(programId))
// Press animation
const scaleValue = useRef(new Animated.Value(1)).current
const handlePressIn = useCallback(() => {
Animated.spring(scaleValue, {
toValue: 0.97,
useNativeDriver: true,
speed: 50,
bounciness: 4,
}).start()
}, [scaleValue])
const handlePressOut = useCallback(() => {
Animated.spring(scaleValue, {
toValue: 1,
useNativeDriver: true,
speed: 30,
bounciness: 6,
}).start()
}, [scaleValue])
const statusText = {
'not-started': t('programs.status.notStarted'),
'in-progress': `${completion}% ${t('programs.status.complete')}`,
'completed': t('programs.status.completed'),
}[programStatus]
const handlePress = () => {
haptics.buttonTap()
onPress()
}
return (
<View
style={styles.programCard}
testID={`program-card-${programId}`}
>
{/* Glass Background */}
<BlurView
intensity={colors.glass.blurMedium}
tint={colors.glass.blurTint}
style={StyleSheet.absoluteFill}
/>
{/* Color Gradient Overlay */}
<LinearGradient
colors={[meta.gradient[0] + '40', meta.gradient[1] + '18', 'transparent']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
{/* Top Accent Line */}
<LinearGradient
colors={meta.gradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.accentLine}
/>
<View style={styles.programCardContent}>
{/* Icon + Title Row */}
<View style={styles.programCardHeader}>
{/* Gradient Icon Circle */}
<View style={styles.programIconWrapper}>
<LinearGradient
colors={meta.gradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.programIconGradient}
/>
<View style={styles.programIconInner}>
<Icon name={meta.icon} size={24} tintColor="#FFFFFF" />
</View>
</View>
<View style={styles.programHeaderText}>
<View style={styles.programTitleRow}>
<StyledText size={FONTS.HEADLINE} weight="bold" color={colors.text.primary} style={{ flex: 1 }}>
{t(`content:programs.${program.id}.title`)}
</StyledText>
{programStatus !== 'not-started' && (
<View style={[styles.statusBadge, { backgroundColor: meta.accent + '20', borderColor: meta.accent + '35' }]}>
<StyledText size={11} weight="semibold" color={meta.accent}>
{statusText}
</StyledText>
</View>
)}
</View>
<StyledText size={FONTS.CAPTION} color={colors.text.secondary} numberOfLines={2} style={{ lineHeight: 18 }}>
{t(`content:programs.${program.id}.description`)}
</StyledText>
</View>
</View>
{/* Progress Bar (if started) */}
{programStatus !== 'not-started' && (
<View style={styles.progressContainer}>
<View style={styles.progressBar}>
<View style={styles.progressFillWrapper}>
<LinearGradient
colors={meta.gradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[
styles.progressFill,
{ width: `${Math.max(completion, 2)}%` },
]}
/>
</View>
</View>
<StyledText size={11} color={colors.text.tertiary}>
{programStatus === 'completed'
? t('programs.allWorkoutsComplete')
: `${completion}% ${t('programs.complete')}`
}
</StyledText>
</View>
)}
{/* Stats — inline text, not chips */}
<StyledText size={12} color={colors.text.tertiary} style={styles.programMeta}>
{program.durationWeeks} {t('programs.weeks')} · {program.workoutsPerWeek}×{t('programs.perWeek')} · {program.totalWorkouts} {t('programs.workouts')}
</StyledText>
{/* Premium CTA Button — only interactive element */}
<AnimatedPressable
style={[
styles.ctaButtonWrapper,
{ transform: [{ scale: scaleValue }] },
]}
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
testID={`program-${programId}-cta`}
>
<LinearGradient
colors={meta.gradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.ctaButton}
>
<StyledText size={15} weight="semibold" color="#FFFFFF">
{programStatus === 'not-started'
? t('programs.startProgram')
: programStatus === 'completed'
? t('programs.restart')
: t('programs.continue')
}
</StyledText>
<Icon
name={programStatus === 'completed' ? 'arrow.clockwise' : 'arrow.right'}
size={17}
tintColor="#FFFFFF"
style={styles.ctaIcon}
/>
</LinearGradient>
</AnimatedPressable>
</View>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// QUICK STATS ROW
// ═══════════════════════════════════════════════════════════════════════════
function QuickStats() {
const { t } = useTranslation('screens')
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const streak = useActivityStore((s) => s.streak)
const history = useActivityStore((s) => s.history)
const weeklyActivity = useMemo(() => getWeeklyActivity(history), [history])
const thisWeekCount = weeklyActivity.filter((d) => d.completed).length
const totalMinutes = useMemo(() => history.reduce((sum, r) => sum + r.durationMinutes, 0), [history])
const stats = [
{ icon: 'flame.fill' as const, value: streak.current, label: t('home.statsStreak'), color: BRAND.PRIMARY },
{ icon: 'calendar' as const, value: `${thisWeekCount}/7`, label: t('home.statsThisWeek'), color: '#5AC8FA' },
{ icon: 'clock' as const, value: totalMinutes, label: t('home.statsMinutes'), color: '#30D158' },
]
return (
<View style={styles.quickStatsRow}>
{stats.map((stat) => (
<View key={stat.label} style={styles.quickStatPill}>
<BlurView
intensity={colors.glass.blurLight}
tint={colors.glass.blurTint}
style={StyleSheet.absoluteFill}
/>
<Icon name={stat.icon} size={16} tintColor={stat.color} />
<StyledText size={17} weight="bold" color={colors.text.primary} style={{ fontVariant: ['tabular-nums'] }}>
{String(stat.value)}
</StyledText>
<StyledText size={11} color={colors.text.tertiary}>
{stat.label}
</StyledText>
</View>
))}
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// ASSESSMENT CARD
// ═══════════════════════════════════════════════════════════════════════════
function AssessmentCard({ onPress }: { onPress: () => void }) {
const { t } = useTranslation('screens')
const haptics = useHaptics()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const isCompleted = useProgramStore((s) => s.assessment.isCompleted)
if (isCompleted) return null
const handlePress = () => {
haptics.buttonTap()
onPress()
}
return (
<Pressable
style={styles.assessmentCard}
onPress={handlePress}
testID="assessment-card"
>
{/* Glass Background */}
<BlurView
intensity={colors.glass.blurMedium}
tint={colors.glass.blurTint}
style={StyleSheet.absoluteFill}
/>
{/* Subtle brand gradient overlay */}
<LinearGradient
colors={[`${BRAND.PRIMARY}18`, 'transparent']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<View style={styles.assessmentContent}>
{/* Gradient Icon Circle */}
<View style={styles.assessmentIconCircle}>
<LinearGradient
colors={[BRAND.PRIMARY, '#FF3B30']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<View style={styles.assessmentIconInner}>
<Icon name="clipboard" size={22} tintColor="#FFFFFF" />
</View>
</View>
<View style={styles.assessmentText}>
<StyledText size={FONTS.HEADLINE} weight="semibold" color={colors.text.primary}>
{t('assessment.title')}
</StyledText>
<StyledText size={FONTS.CAPTION} color={colors.text.secondary} style={{ marginTop: 2 }}>
{ASSESSMENT_WORKOUT.duration} {t('assessment.duration')} · {ASSESSMENT_WORKOUT.exercises.length} {t('assessment.movements')}
</StyledText>
</View>
<View style={styles.assessmentArrow}>
<Icon name="arrow.right" size={16} tintColor={BRAND.PRIMARY} />
</View>
</View>
</Pressable>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN SCREEN
// ═══════════════════════════════════════════════════════════════════════════
export default function HomeScreen() {
const { t } = useTranslation('screens')
const insets = useSafeAreaInsets()
const router = useRouter()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const haptics = useHaptics()
const userName = useUserStore((s) => s.profile.name)
const selectedProgram = useProgramStore((s) => s.selectedProgramId)
const changeProgram = useProgramStore((s) => s.changeProgram)
const streak = useActivityStore((s) => s.streak)
const greeting = (() => {
const hour = new Date().getHours()
if (hour < 12) return t('common:greetings.morning')
if (hour < 18) return t('common:greetings.afternoon')
return t('common:greetings.evening')
})()
const handleProgramPress = (programId: ProgramId) => {
// Navigate to program detail
router.push(`/program/${programId}` as any)
}
const handleAssessmentPress = () => {
router.push('/assessment' as any)
}
const handleSwitchProgram = () => {
haptics.buttonTap()
changeProgram(null as any)
}
const programOrder: ProgramId[] = ['upper-body', 'lower-body', 'full-body']
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Ambient gradient glow at top */}
<LinearGradient
colors={['rgba(255, 107, 53, 0.06)', 'rgba(255, 107, 53, 0.02)', 'transparent']}
start={{ x: 0, y: 0 }}
end={{ x: 0.5, y: 1 }}
style={styles.ambientGlow}
/>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
showsVerticalScrollIndicator={false}
>
{/* Hero Section */}
<View style={styles.heroSection}>
<View style={styles.heroGreetingRow}>
<StyledText size={FONTS.BODY} color={colors.text.tertiary}>
{greeting}
</StyledText>
{/* Inline streak badge */}
{streak.current > 0 && (
<View style={styles.streakBadge}>
<Icon name="flame.fill" size={13} tintColor={BRAND.PRIMARY} />
<StyledText size={12} weight="bold" color={BRAND.PRIMARY} style={{ fontVariant: ['tabular-nums'] }}>
{streak.current}
</StyledText>
</View>
)}
</View>
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary} style={styles.heroName}>
{userName}
</StyledText>
<StyledText size={FONTS.CAPTION} color={colors.text.secondary} style={styles.heroSubtitle}>
{selectedProgram
? t('home.continueYourJourney')
: t('home.chooseYourPath')
}
</StyledText>
</View>
{/* Quick Stats Row */}
<QuickStats />
{/* Assessment Card (if not completed and feature enabled) */}
{FEATURE_FLAGS.ASSESSMENT_ENABLED && (
<AssessmentCard onPress={handleAssessmentPress} />
)}
{/* Program Cards */}
<View style={styles.programsSection}>
<View style={styles.sectionHeader}>
<StyledText size={FONTS.TITLE} weight="bold" color={colors.text.primary}>
{t('home.yourPrograms')}
</StyledText>
<StyledText size={FONTS.CAPTION} color={colors.text.tertiary} style={styles.sectionSubtitle}>
{t('home.programsSubtitle')}
</StyledText>
</View>
{programOrder.map((programId) => (
<ProgramCard
key={programId}
programId={programId}
onPress={() => handleProgramPress(programId)}
/>
))}
</View>
{/* Switch Program Option (if has progress) */}
{selectedProgram && (
<Pressable
style={styles.switchProgramButton}
onPress={handleSwitchProgram}
>
<BlurView
intensity={colors.glass.blurLight}
tint={colors.glass.blurTint}
style={StyleSheet.absoluteFill}
/>
<Icon name="shuffle" size={16} tintColor={colors.text.secondary} />
<StyledText size={14} weight="medium" color={colors.text.secondary}>
{t('home.switchProgram')}
</StyledText>
</Pressable>
)}
</ScrollView>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg.base,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
},
// Ambient gradient glow
ambientGlow: {
position: 'absolute',
top: 0,
left: 0,
width: 300,
height: 300,
borderRadius: 150,
},
// Hero Section
heroSection: {
marginTop: SPACING[4],
marginBottom: SPACING[7],
},
heroGreetingRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
streakBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[1],
paddingHorizontal: SPACING[3],
paddingVertical: SPACING[1],
borderRadius: RADIUS.FULL,
backgroundColor: `${BRAND.PRIMARY}15`,
borderWidth: 1,
borderColor: `${BRAND.PRIMARY}30`,
borderCurve: 'continuous',
},
heroName: {
marginTop: SPACING[1],
},
heroSubtitle: {
marginTop: SPACING[2],
},
// Quick Stats Row
quickStatsRow: {
flexDirection: 'row',
gap: SPACING[3],
marginBottom: SPACING[7],
},
quickStatPill: {
flex: 1,
alignItems: 'center',
paddingVertical: SPACING[4],
borderRadius: RADIUS.GLASS_CARD,
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.border.glass,
borderCurve: 'continuous',
gap: SPACING[1],
backgroundColor: colors.glass.base.backgroundColor,
},
// Assessment Card
assessmentCard: {
borderRadius: RADIUS.GLASS_CARD,
overflow: 'hidden',
padding: SPACING[5],
marginBottom: SPACING[8],
borderWidth: 1,
borderColor: colors.border.glassStrong,
borderCurve: 'continuous',
backgroundColor: colors.glass.base.backgroundColor,
},
assessmentContent: {
flexDirection: 'row',
alignItems: 'center',
},
assessmentIconCircle: {
width: 44,
height: 44,
borderRadius: 22,
overflow: 'hidden',
borderCurve: 'continuous',
marginRight: SPACING[4],
},
assessmentIconInner: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
},
assessmentText: {
flex: 1,
},
assessmentArrow: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: `${BRAND.PRIMARY}18`,
alignItems: 'center',
justifyContent: 'center',
borderCurve: 'continuous',
},
// Programs Section
programsSection: {
marginTop: SPACING[2],
},
sectionHeader: {
marginBottom: SPACING[6],
},
sectionSubtitle: {
marginTop: SPACING[1],
},
// Program Card
programCard: {
borderRadius: RADIUS.XL,
marginBottom: SPACING[6],
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.border.glassStrong,
borderCurve: 'continuous',
backgroundColor: colors.glass.base.backgroundColor,
},
accentLine: {
height: 2,
width: '100%',
},
programCardContent: {
padding: SPACING[5],
paddingRight: SPACING[6],
},
programCardHeader: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: SPACING[4],
marginBottom: SPACING[4],
},
// Gradient icon circle
programIconWrapper: {
width: 48,
height: 48,
borderRadius: 24,
overflow: 'hidden',
borderCurve: 'continuous',
},
programIconGradient: {
...StyleSheet.absoluteFillObject,
},
programIconInner: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
},
programHeaderText: {
flex: 1,
paddingBottom: SPACING[1],
},
programTitleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[2],
marginBottom: SPACING[1],
},
statusBadge: {
paddingHorizontal: SPACING[2],
paddingVertical: 2,
borderRadius: RADIUS.FULL,
borderWidth: 1,
},
programTitle: {
marginBottom: SPACING[1],
},
programDescription: {
marginBottom: SPACING[4],
lineHeight: 20,
},
// Progress
progressContainer: {
marginBottom: SPACING[4],
},
progressBar: {
height: 8,
borderRadius: 4,
marginBottom: SPACING[2],
overflow: 'hidden',
backgroundColor: colors.glass.inset.backgroundColor,
borderCurve: 'continuous',
},
progressFillWrapper: {
flex: 1,
},
progressFill: {
height: '100%',
borderRadius: 4,
borderCurve: 'continuous',
},
// Stats as inline meta text
programMeta: {
marginBottom: SPACING[4],
},
// Premium CTA Button
ctaButtonWrapper: {
borderRadius: RADIUS.LG,
overflow: 'hidden',
borderCurve: 'continuous',
},
ctaButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: SPACING[4],
paddingHorizontal: SPACING[5],
borderRadius: RADIUS.LG,
borderCurve: 'continuous',
},
ctaIcon: {
marginLeft: SPACING[2],
},
// Switch Program — glass pill
switchProgramButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
gap: SPACING[2],
paddingVertical: SPACING[3],
paddingHorizontal: SPACING[6],
marginTop: SPACING[2],
borderRadius: RADIUS.FULL,
borderWidth: 1,
borderColor: colors.border.glass,
borderCurve: 'continuous',
overflow: 'hidden',
backgroundColor: colors.glass.base.backgroundColor,
},
})
}

View File

@@ -1,451 +0,0 @@
/**
* TabataFit Profile Screen — Premium React Native
* Apple Fitness+ inspired design, pure React Native components
*/
import { useRouter } from 'expo-router'
import {
View,
ScrollView,
StyleSheet,
Pressable,
Switch,
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import * as Linking from 'expo-linking'
import Constants from 'expo-constants'
import { useTranslation } from 'react-i18next'
import { useMemo, useState } from 'react'
import { useUserStore, useActivityStore } from '@/src/shared/stores'
import { requestNotificationPermissions, usePurchases } from '@/src/shared/hooks'
import { useThemeColors, BRAND } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { StyledText } from '@/src/shared/components/StyledText'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { DataDeletionModal } from '@/src/shared/components/DataDeletionModal'
import { deleteSyncedData } from '@/src/shared/services/sync'
// ═══════════════════════════════════════════════════════════════════════════
// COMPONENT: PROFILE SCREEN
// ═══════════════════════════════════════════════════════════════════════════
export default function ProfileScreen() {
const { t } = useTranslation('screens')
const router = useRouter()
const insets = useSafeAreaInsets()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const profile = useUserStore((s) => s.profile)
const settings = useUserStore((s) => s.settings)
const updateSettings = useUserStore((s) => s.updateSettings)
const updateProfile = useUserStore((s) => s.updateProfile)
const setSyncStatus = useUserStore((s) => s.setSyncStatus)
const { restorePurchases, isPremium } = usePurchases()
const [showDeleteModal, setShowDeleteModal] = useState(false)
const planLabel = isPremium ? 'TabataFit+' : t('profile.freePlan')
const avatarInitial = profile.name?.[0]?.toUpperCase() || 'U'
// Real stats from activity store
const history = useActivityStore((s) => s.history)
const streak = useActivityStore((s) => s.streak)
const stats = useMemo(() => ({
workouts: history.length,
streak: streak.current,
calories: history.reduce((sum, r) => sum + (r.calories ?? 0), 0),
}), [history, streak])
const handleSignOut = () => {
updateProfile({
name: '',
email: '',
subscription: 'free',
onboardingCompleted: false,
})
router.replace('/onboarding')
}
const handleRestore = async () => {
await restorePurchases()
}
const handleDeleteData = async () => {
const result = await deleteSyncedData()
if (result.success) {
setSyncStatus('unsynced', null)
setShowDeleteModal(false)
}
}
const handleReminderToggle = async (enabled: boolean) => {
if (enabled) {
const granted = await requestNotificationPermissions()
if (!granted) return
}
updateSettings({ reminders: enabled })
}
const handleRateApp = () => {
Linking.openURL('https://apps.apple.com/app/tabatafit/id1234567890')
}
const handleContactUs = () => {
Linking.openURL('mailto:contact@tabatafit.app')
}
const handlePrivacyPolicy = () => {
router.push('/privacy')
}
const handleFAQ = () => {
Linking.openURL('https://tabatafit.app/faq')
}
// App version
const appVersion = Constants.expoConfig?.version ?? '1.0.0'
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
showsVerticalScrollIndicator={false}
>
{/* ════════════════════════════════════════════════════════════════════
PROFILE HEADER CARD
═══════════════════════════════════════════════════════════════════ */}
<View style={styles.section}>
<View style={styles.headerContainer}>
{/* Avatar with gradient background */}
<View style={styles.avatarContainer}>
<StyledText size={48} weight="bold" color="#FFFFFF">
{avatarInitial}
</StyledText>
</View>
{/* Name & Plan */}
<View style={styles.nameContainer}>
<StyledText size={22} weight="semibold" style={{ textAlign: 'center' }}>
{profile.name || t('profile.guest')}
</StyledText>
<View style={styles.planContainer}>
<StyledText size={15} color={isPremium ? BRAND.PRIMARY : colors.text.tertiary}>
{planLabel}
</StyledText>
{isPremium && (
<StyledText size={12} color={BRAND.PRIMARY}>
</StyledText>
)}
</View>
</View>
{/* Stats Row */}
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<StyledText size={20} weight="bold" color={BRAND.PRIMARY} style={{ textAlign: 'center' }}>
🔥 {stats.workouts}
</StyledText>
<StyledText size={12} color={colors.text.tertiary} style={{ textAlign: 'center' }}>
{t('profile.statsWorkouts')}
</StyledText>
</View>
<View style={styles.statItem}>
<StyledText size={20} weight="bold" color={BRAND.PRIMARY} style={{ textAlign: 'center' }}>
📅 {stats.streak}
</StyledText>
<StyledText size={12} color={colors.text.tertiary} style={{ textAlign: 'center' }}>
{t('profile.statsStreak')}
</StyledText>
</View>
<View style={styles.statItem}>
<StyledText size={20} weight="bold" color={BRAND.PRIMARY} style={{ textAlign: 'center' }}>
{Math.round(stats.calories / 1000)}k
</StyledText>
<StyledText size={12} color={colors.text.tertiary} style={{ textAlign: 'center' }}>
{t('profile.statsCalories')}
</StyledText>
</View>
</View>
</View>
</View>
{/* ════════════════════════════════════════════════════════════════════
UPGRADE CTA (FREE USERS ONLY)
═══════════════════════════════════════════════════════════════════ */}
{!isPremium && (
<View style={styles.section}>
<Pressable
style={styles.premiumContainer}
onPress={() => router.push('/paywall')}
>
<View style={styles.premiumContent}>
<StyledText size={17} weight="semibold" color={BRAND.PRIMARY}>
{t('profile.upgradeTitle')}
</StyledText>
<StyledText size={15} color={colors.text.tertiary} style={{ marginTop: SPACING[1] }}>
{t('profile.upgradeDescription')}
</StyledText>
</View>
<StyledText size={15} color={BRAND.PRIMARY} style={{ marginTop: SPACING[3] }}>
{t('profile.learnMore')}
</StyledText>
</Pressable>
</View>
)}
{/* ════════════════════════════════════════════════════════════════════
WORKOUT SETTINGS
═══════════════════════════════════════════════════════════════════ */}
<StyledText style={styles.sectionHeader}>{t('profile.sectionWorkout')}</StyledText>
<View style={styles.section}>
<View style={styles.row}>
<StyledText style={styles.rowLabel}>{t('profile.hapticFeedback')}</StyledText>
<Switch
value={settings.haptics}
onValueChange={(v) => updateSettings({ haptics: v })}
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
thumbColor="#FFFFFF"
/>
</View>
<View style={styles.row}>
<StyledText style={styles.rowLabel}>{t('profile.soundEffects')}</StyledText>
<Switch
value={settings.soundEffects}
onValueChange={(v) => updateSettings({ soundEffects: v })}
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
thumbColor="#FFFFFF"
/>
</View>
<View style={[styles.row, styles.rowLast]}>
<StyledText style={styles.rowLabel}>{t('profile.voiceCoaching')}</StyledText>
<Switch
value={settings.voiceCoaching}
onValueChange={(v) => updateSettings({ voiceCoaching: v })}
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* ════════════════════════════════════════════════════════════════════
NOTIFICATIONS
═══════════════════════════════════════════════════════════════════ */}
<StyledText style={styles.sectionHeader}>{t('profile.sectionNotifications')}</StyledText>
<View style={styles.section}>
<View style={styles.row}>
<StyledText style={styles.rowLabel}>{t('profile.dailyReminders')}</StyledText>
<Switch
value={settings.reminders}
onValueChange={handleReminderToggle}
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
thumbColor="#FFFFFF"
/>
</View>
{settings.reminders && (
<View style={styles.rowTime}>
<StyledText style={styles.rowLabel}>{t('profile.reminderTime')}</StyledText>
<StyledText style={styles.rowValue}>{settings.reminderTime}</StyledText>
</View>
)}
</View>
{/* ════════════════════════════════════════════════════════════════════
PERSONALIZATION (PREMIUM ONLY)
═══════════════════════════════════════════════════════════════════ */}
{isPremium && (
<>
<StyledText style={styles.sectionHeader}>{t('profile.sectionPersonalization')}</StyledText>
<View style={styles.section}>
<View style={[styles.row, styles.rowLast]}>
<StyledText style={styles.rowLabel}>
{profile.syncStatus === 'synced' ? t('profile.personalizationEnabled') : t('profile.personalizationDisabled')}
</StyledText>
<StyledText
size={14}
color={profile.syncStatus === 'synced' ? BRAND.SUCCESS : colors.text.tertiary}
>
{profile.syncStatus === 'synced' ? '✓' : '○'}
</StyledText>
</View>
</View>
</>
)}
{/* ════════════════════════════════════════════════════════════════════
ABOUT
═══════════════════════════════════════════════════════════════════ */}
<StyledText style={styles.sectionHeader}>{t('profile.sectionAbout')}</StyledText>
<View style={styles.section}>
<View style={styles.row}>
<StyledText style={styles.rowLabel}>{t('profile.version')}</StyledText>
<StyledText style={styles.rowValue}>{appVersion}</StyledText>
</View>
<Pressable style={styles.row} onPress={handleRateApp}>
<StyledText style={styles.rowLabel}>{t('profile.rateApp')}</StyledText>
<StyledText style={styles.rowValue}></StyledText>
</Pressable>
<Pressable style={styles.row} onPress={handleContactUs}>
<StyledText style={styles.rowLabel}>{t('profile.contactUs')}</StyledText>
<StyledText style={styles.rowValue}></StyledText>
</Pressable>
<Pressable style={styles.row} onPress={handleFAQ}>
<StyledText style={styles.rowLabel}>{t('profile.faq')}</StyledText>
<StyledText style={styles.rowValue}></StyledText>
</Pressable>
<Pressable style={[styles.row, styles.rowLast]} onPress={handlePrivacyPolicy}>
<StyledText style={styles.rowLabel}>{t('profile.privacyPolicy')}</StyledText>
<StyledText style={styles.rowValue}></StyledText>
</Pressable>
</View>
{/* ════════════════════════════════════════════════════════════════════
ACCOUNT (PREMIUM USERS ONLY)
═══════════════════════════════════════════════════════════════════ */}
{isPremium && (
<>
<StyledText style={styles.sectionHeader}>{t('profile.sectionAccount')}</StyledText>
<View style={styles.section}>
<Pressable style={[styles.row, styles.rowLast]} onPress={handleRestore}>
<StyledText style={styles.rowLabel}>{t('profile.restorePurchases')}</StyledText>
<StyledText style={styles.rowValue}></StyledText>
</Pressable>
</View>
</>
)}
{/* ════════════════════════════════════════════════════════════════════
SIGN OUT
═══════════════════════════════════════════════════════════════════ */}
<View style={[styles.section, styles.signOutSection]}>
<Pressable style={styles.button} onPress={handleSignOut}>
<StyledText style={styles.destructive}>{t('profile.signOut')}</StyledText>
</Pressable>
</View>
</ScrollView>
{/* Data Deletion Modal */}
<DataDeletionModal
visible={showDeleteModal}
onDelete={handleDeleteData}
onCancel={() => setShowDeleteModal(false)}
/>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg.base,
},
scrollView: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
},
section: {
marginHorizontal: SPACING[4],
marginTop: SPACING[5],
backgroundColor: colors.bg.surface,
borderRadius: RADIUS.MD,
overflow: 'hidden',
},
sectionHeader: {
fontSize: 13,
fontWeight: '600',
color: colors.text.tertiary,
textTransform: 'uppercase',
marginLeft: SPACING[8],
marginTop: SPACING[5],
marginBottom: SPACING[2],
},
headerContainer: {
alignItems: 'center',
paddingVertical: SPACING[6],
paddingHorizontal: SPACING[4],
},
avatarContainer: {
width: 90,
height: 90,
borderRadius: 45,
backgroundColor: BRAND.PRIMARY,
justifyContent: 'center',
alignItems: 'center',
boxShadow: `0 4px 20px ${BRAND.PRIMARY}80`,
},
nameContainer: {
marginTop: SPACING[4],
alignItems: 'center',
},
planContainer: {
flexDirection: 'row',
alignItems: 'center',
marginTop: SPACING[1],
gap: SPACING[1],
},
statsContainer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: SPACING[4],
gap: SPACING[8],
},
statItem: {
alignItems: 'center',
},
premiumContainer: {
paddingVertical: SPACING[4],
paddingHorizontal: SPACING[4],
},
premiumContent: {
gap: SPACING[1],
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: SPACING[3],
paddingHorizontal: SPACING[4],
borderBottomWidth: 0.5,
borderBottomColor: colors.border.glassLight,
},
rowLast: {
borderBottomWidth: 0,
},
rowLabel: {
fontSize: 17,
color: colors.text.primary,
},
rowValue: {
fontSize: 17,
color: colors.text.tertiary,
},
rowTime: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: SPACING[3],
paddingHorizontal: SPACING[4],
borderTopWidth: 0.5,
borderTopColor: colors.border.glassLight,
},
button: {
paddingVertical: SPACING[3] + 2,
alignItems: 'center',
},
destructive: {
fontSize: 17,
color: BRAND.DANGER,
},
signOutSection: {
marginTop: SPACING[5],
},
})
}

View File

@@ -1,40 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Feb 19, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5001 | 9:35 AM | 🔵 | Host Wrapper Located at Root Layout Level | ~153 |
| #4964 | 9:23 AM | 🔴 | Added Host Wrapper to Root Layout | ~228 |
| #4963 | 9:22 AM | ✅ | Root layout wraps Stack in View with pure black background | ~279 |
| #4910 | 8:16 AM | 🟣 | Added Workout Detail and Complete Screen Routes | ~348 |
### Feb 20, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5115 | 8:57 AM | 🔵 | Root Layout Stack Configuration with Screen Animations | ~256 |
| #5061 | 8:47 AM | 🔵 | Expo Router Tab Navigation Structure Found | ~196 |
| #5053 | 8:23 AM | ✅ | Completed removal of all Host wrappers from application | ~255 |
| #5052 | " | ✅ | Removed Host wrapper from root layout entirely | ~224 |
| #5019 | 8:13 AM | 🔵 | Root layout properly wraps Stack with Host component | ~198 |
### Feb 28, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5598 | 9:22 PM | 🟣 | Enabled PostHog analytics in development mode | ~253 |
| #5597 | " | 🔄 | PostHogProvider initialization updated with client check and autocapture config | ~303 |
| #5589 | 7:51 PM | 🟣 | PostHog screen tracking added to onboarding flow | ~246 |
| #5588 | 7:50 PM | ✅ | Added trackScreen function to onboarding analytics imports | ~203 |
| #5585 | " | ✅ | Enhanced PostHogProvider initialization with null safety | ~239 |
| #5584 | 7:49 PM | ✅ | Imported trackScreen function in root layout | ~202 |
| #5583 | " | 🟣 | PostHog user identification added to onboarding completion | ~291 |
| #5582 | " | ✅ | Enhanced onboarding analytics with user identification | ~187 |
| #5579 | 7:47 PM | 🔵 | Comprehensive analytics tracking in onboarding flow | ~345 |
| #5575 | 7:44 PM | 🔵 | PostHog integration architecture in root layout | ~279 |
| #5572 | 7:43 PM | 🔵 | PostHog integration points identified | ~228 |
</claude-mem-context>

View File

@@ -1,216 +0,0 @@
/**
* TabataFit Root Layout
* Expo Router v3 + Inter font loading
* Waits for font + store hydration before rendering
*/
import '@/src/shared/i18n'
import '@/src/shared/i18n/types'
import { useState, useEffect, useCallback } from 'react'
import { Stack } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import { View } from 'react-native'
import * as SplashScreen from 'expo-splash-screen'
import * as Notifications from 'expo-notifications'
import {
useFonts,
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
Inter_900Black,
} from '@expo-google-fonts/inter'
import { PostHogProvider } from 'posthog-react-native'
import { ThemeProvider, useThemeColors } from '@/src/shared/theme'
import { useUserStore } from '@/src/shared/stores'
import { useNotifications } from '@/src/shared/hooks'
import { initializePurchases } from '@/src/shared/services/purchases'
import { initializeAnalytics, getPostHogClient, trackScreen } from '@/src/shared/services/analytics'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: false,
shouldPlaySound: false,
shouldSetBadge: false,
shouldShowBanner: false,
shouldShowList: false,
}),
})
SplashScreen.preventAutoHideAsync()
// Create React Query Client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 60 * 24, // 24 hours
retry: 2,
refetchOnWindowFocus: false,
},
},
})
function RootLayoutInner() {
const colors = useThemeColors()
const [fontsLoaded] = useFonts({
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
Inter_900Black,
})
useNotifications()
// Wait for persisted store to hydrate from AsyncStorage
const [hydrated, setHydrated] = useState(useUserStore.persist.hasHydrated())
useEffect(() => {
const unsub = useUserStore.persist.onFinishHydration(() => setHydrated(true))
return unsub
}, [])
// Initialize RevenueCat + PostHog after hydration
useEffect(() => {
if (hydrated) {
initializePurchases().catch((err) => {
console.error('Failed to initialize RevenueCat:', err)
})
initializeAnalytics().catch((err) => {
console.error('Failed to initialize PostHog:', err)
})
}
}, [hydrated])
const onLayoutRootView = useCallback(async () => {
if (fontsLoaded && hydrated) {
await SplashScreen.hideAsync()
}
}, [fontsLoaded, hydrated])
if (!fontsLoaded || !hydrated) {
return null
}
const content = (
<QueryClientProvider client={queryClient}>
<View style={{ flex: 1, backgroundColor: colors.bg.base }} onLayout={onLayoutRootView}>
<StatusBar style={colors.statusBarStyle} />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.bg.base },
animation: 'slide_from_right',
}}
>
<Stack.Screen name="(tabs)" />
<Stack.Screen
name="onboarding"
options={{
animation: 'fade',
}}
/>
<Stack.Screen
name="workout/[id]"
options={{
headerShown: true,
headerTransparent: true,
headerBlurEffect: colors.colorScheme === 'dark' ? 'dark' : 'light',
headerShadowVisible: false,
headerTitle: '',
headerBackButtonDisplayMode: 'minimal',
headerTintColor: colors.colorScheme === 'dark' ? '#FFFFFF' : '#000000',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="workout/category/[id]"
options={{
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="program/[id]"
options={{
headerShown: true,
headerTransparent: true,
headerBlurEffect: colors.colorScheme === 'dark' ? 'dark' : 'light',
headerShadowVisible: false,
headerTitle: '',
headerBackButtonDisplayMode: 'minimal',
headerTintColor: colors.colorScheme === 'dark' ? '#FFFFFF' : '#000000',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="collection/[id]"
options={{
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="assessment"
options={{
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="player/[id]"
options={{
presentation: 'fullScreenModal',
animation: 'fade',
}}
/>
<Stack.Screen
name="complete/[id]"
options={{
animation: 'fade',
}}
/>
<Stack.Screen
name="explore-filters"
options={{
presentation: 'formSheet',
headerShown: false,
sheetGrabberVisible: true,
sheetAllowedDetents: [0.5],
}}
/>
</Stack>
</View>
</QueryClientProvider>
)
const posthogClient = getPostHogClient()
// Only wrap with PostHogProvider if client is initialized
if (!posthogClient) {
return content
}
return (
<PostHogProvider
client={posthogClient}
autocapture={{
captureScreens: true,
captureTouches: true,
}}
>
{content}
</PostHogProvider>
)
}
export default function RootLayout() {
return (
<ThemeProvider>
<RootLayoutInner />
</ThemeProvider>
)
}

View File

@@ -1,35 +0,0 @@
import { Stack } from 'expo-router'
import { AdminAuthProvider, useAdminAuth } from '../../src/admin/components/AdminAuthProvider'
import { View, ActivityIndicator } from 'react-native'
import { Redirect } from 'expo-router'
function AdminLayoutContent({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isAdmin, isLoading } = useAdminAuth()
if (isLoading) {
return (
<View style={{ flex: 1, backgroundColor: '#000', justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" color="#FF6B35" />
</View>
)
}
if (!isAuthenticated || !isAdmin) {
return <Redirect href="/admin/login" />
}
return (
<>
<Stack.Screen options={{ headerShown: false }} />
{children}
</>
)
}
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<AdminAuthProvider>
<AdminLayoutContent>{children}</AdminLayoutContent>
</AdminAuthProvider>
)
}

View File

@@ -1,201 +0,0 @@
import { useState } from 'react'
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useCollections } from '../../src/shared/hooks/useSupabaseData'
import { adminService } from '../../src/admin/services/adminService'
import type { Collection } from '../../src/shared/types'
export default function AdminCollectionsScreen() {
const router = useRouter()
const { collections, loading, refetch } = useCollections()
const [updatingId, setUpdatingId] = useState<string | null>(null)
const handleDelete = (collection: Collection) => {
Alert.alert(
'Delete Collection',
`Are you sure you want to delete "${collection.title}"?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
Alert.alert('Info', 'Collection deletion not yet implemented')
}
},
]
)
}
if (loading) {
return (
<View style={[styles.container, styles.centered]}>
<ActivityIndicator size="large" color="#FF6B35" />
</View>
)
}
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Text style={styles.backText}> Back</Text>
</TouchableOpacity>
<Text style={styles.title}>Collections</Text>
<TouchableOpacity style={styles.addButton}>
<Text style={styles.addButtonText}>+ Add</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.content}>
{collections.map((collection) => (
<View key={collection.id} style={styles.collectionCard}>
<View style={styles.iconContainer}>
<Text style={styles.icon}>{collection.icon}</Text>
</View>
<View style={styles.collectionInfo}>
<Text style={styles.collectionTitle}>{collection.title}</Text>
<Text style={styles.collectionDescription}>{collection.description}</Text>
<Text style={styles.collectionMeta}>
{collection.workoutIds.length} workouts
</Text>
</View>
<View style={styles.actions}>
<TouchableOpacity style={styles.editButton}>
<Text style={styles.editText}>Edit</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.deleteButton, updatingId === collection.id && styles.disabledButton]}
onPress={() => handleDelete(collection)}
disabled={updatingId === collection.id}
>
<Text style={styles.deleteText}>
{updatingId === collection.id ? '...' : 'Delete'}
</Text>
</TouchableOpacity>
</View>
</View>
))}
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
centered: {
justifyContent: 'center',
alignItems: 'center',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
borderBottomWidth: 1,
borderBottomColor: '#1C1C1E',
},
backButton: {
padding: 8,
},
backText: {
color: '#FF6B35',
fontSize: 16,
},
title: {
fontSize: 20,
fontWeight: 'bold',
color: '#fff',
},
addButton: {
backgroundColor: '#FF6B35',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
addButtonText: {
color: '#000',
fontWeight: 'bold',
},
content: {
flex: 1,
padding: 16,
},
collectionCard: {
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 16,
marginBottom: 12,
flexDirection: 'row',
alignItems: 'center',
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#2C2C2E',
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
icon: {
fontSize: 24,
},
collectionInfo: {
flex: 1,
},
collectionTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
marginBottom: 4,
},
collectionDescription: {
fontSize: 14,
color: '#999',
marginBottom: 4,
},
collectionMeta: {
fontSize: 12,
color: '#666',
},
actions: {
flexDirection: 'row',
gap: 8,
},
editButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
editText: {
color: '#5AC8FA',
},
deleteButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
disabledButton: {
opacity: 0.5,
},
deleteText: {
color: '#FF3B30',
},
})

View File

@@ -1,212 +0,0 @@
import { useState, useCallback } from 'react'
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
RefreshControl,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useAdminAuth } from '../../src/admin/components/AdminAuthProvider'
import { useWorkouts, useTrainers, useCollections } from '../../src/shared/hooks/useSupabaseData'
export default function AdminDashboardScreen() {
const router = useRouter()
const { signOut } = useAdminAuth()
const [refreshing, setRefreshing] = useState(false)
const {
workouts,
loading: workoutsLoading,
refetch: refetchWorkouts
} = useWorkouts()
const {
trainers,
loading: trainersLoading,
refetch: refetchTrainers
} = useTrainers()
const {
collections,
loading: collectionsLoading,
refetch: refetchCollections
} = useCollections()
const onRefresh = useCallback(async () => {
setRefreshing(true)
await Promise.all([
refetchWorkouts(),
refetchTrainers(),
refetchCollections(),
])
setRefreshing(false)
}, [refetchWorkouts, refetchTrainers, refetchCollections])
const handleLogout = async () => {
await signOut()
router.replace('/admin/login')
}
const isLoading = workoutsLoading || trainersLoading || collectionsLoading
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Admin Dashboard</Text>
<TouchableOpacity onPress={handleLogout} style={styles.logoutButton}>
<Text style={styles.logoutText}>Logout</Text>
</TouchableOpacity>
</View>
<ScrollView
style={styles.content}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#FF6B35" />
}
>
<View style={styles.statsGrid}>
<View style={styles.statCard}>
<Text style={styles.statNumber}>{workouts.length}</Text>
<Text style={styles.statLabel}>Workouts</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statNumber}>{trainers.length}</Text>
<Text style={styles.statLabel}>Trainers</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statNumber}>{collections.length}</Text>
<Text style={styles.statLabel}>Collections</Text>
</View>
</View>
<Text style={styles.sectionTitle}>Quick Actions</Text>
<View style={styles.actionsGrid}>
<TouchableOpacity
style={styles.actionCard}
onPress={() => router.push('/admin/workouts')}
>
<Text style={styles.actionIcon}>💪</Text>
<Text style={styles.actionTitle}>Manage Workouts</Text>
<Text style={styles.actionDescription}>Add, edit, or delete workouts</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionCard}
onPress={() => router.push('/admin/trainers')}
>
<Text style={styles.actionIcon}>👥</Text>
<Text style={styles.actionTitle}>Manage Trainers</Text>
<Text style={styles.actionDescription}>Update trainer profiles</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionCard}
onPress={() => router.push('/admin/collections')}
>
<Text style={styles.actionIcon}>📁</Text>
<Text style={styles.actionTitle}>Manage Collections</Text>
<Text style={styles.actionDescription}>Organize workout collections</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionCard}
onPress={() => router.push('/admin/media')}
>
<Text style={styles.actionIcon}>🎬</Text>
<Text style={styles.actionTitle}>Media Library</Text>
<Text style={styles.actionDescription}>Upload videos and images</Text>
</TouchableOpacity>
</View>
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
borderBottomWidth: 1,
borderBottomColor: '#1C1C1E',
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#fff',
},
logoutButton: {
padding: 8,
},
logoutText: {
color: '#FF6B35',
fontSize: 16,
},
content: {
flex: 1,
padding: 20,
},
statsGrid: {
flexDirection: 'row',
gap: 12,
marginBottom: 32,
},
statCard: {
flex: 1,
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 16,
alignItems: 'center',
},
statNumber: {
fontSize: 32,
fontWeight: 'bold',
color: '#FF6B35',
},
statLabel: {
fontSize: 14,
color: '#999',
marginTop: 4,
},
sectionTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#fff',
marginBottom: 16,
},
actionsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
actionCard: {
width: '47%',
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 20,
marginBottom: 12,
},
actionIcon: {
fontSize: 32,
marginBottom: 12,
},
actionTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
marginBottom: 4,
},
actionDescription: {
fontSize: 14,
color: '#999',
},
})

View File

@@ -1,124 +0,0 @@
import { useState } from 'react'
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
} from 'react-native'
import { useAdminAuth } from '../../src/admin/components/AdminAuthProvider'
export default function AdminLoginScreen() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const { signIn, isLoading } = useAdminAuth()
const handleLogin = async () => {
if (!email || !password) {
setError('Please enter both email and password')
return
}
setError('')
try {
await signIn(email, password)
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
}
}
return (
<View style={styles.container}>
<View style={styles.card}>
<Text style={styles.title}>TabataFit Admin</Text>
<Text style={styles.subtitle}>Sign in to manage content</Text>
{error ? <Text style={styles.errorText}>{error}</Text> : null}
<TextInput
style={styles.input}
placeholder="Email"
placeholderTextColor="#666"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
style={styles.input}
placeholder="Password"
placeholderTextColor="#666"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TouchableOpacity
style={styles.button}
onPress={handleLogin}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="#000" />
) : (
<Text style={styles.buttonText}>Sign In</Text>
)}
</TouchableOpacity>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
card: {
backgroundColor: '#1C1C1E',
borderRadius: 16,
padding: 32,
width: '100%',
maxWidth: 400,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#fff',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#999',
marginBottom: 24,
},
errorText: {
color: '#FF3B30',
marginBottom: 16,
},
input: {
backgroundColor: '#2C2C2E',
borderRadius: 8,
padding: 16,
marginBottom: 16,
color: '#fff',
fontSize: 16,
},
button: {
backgroundColor: '#FF6B35',
borderRadius: 8,
padding: 16,
alignItems: 'center',
},
buttonText: {
color: '#000',
fontSize: 16,
fontWeight: 'bold',
},
})

View File

@@ -1,201 +0,0 @@
import { useState } from 'react'
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
} from 'react-native'
import { useRouter } from 'expo-router'
import { supabase } from '../../src/shared/supabase'
export default function AdminMediaScreen() {
const router = useRouter()
const [uploading, setUploading] = useState(false)
const [activeTab, setActiveTab] = useState<'videos' | 'thumbnails' | 'avatars'>('videos')
const handleUpload = async () => {
Alert.alert('Info', 'File upload requires file picker integration. This is a placeholder.')
}
const handleDelete = async (path: string) => {
Alert.alert(
'Delete File',
`Are you sure you want to delete "${path}"?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
const { error } = await supabase.storage
.from(activeTab)
.remove([path])
if (error) throw error
Alert.alert('Success', 'File deleted')
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to delete')
}
}
},
]
)
}
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Text style={styles.backText}> Back</Text>
</TouchableOpacity>
<Text style={styles.title}>Media Library</Text>
<TouchableOpacity style={styles.uploadButton} onPress={handleUpload}>
<Text style={styles.uploadButtonText}>Upload</Text>
</TouchableOpacity>
</View>
<View style={styles.tabs}>
{(['videos', 'thumbnails', 'avatars'] as const).map((tab) => (
<TouchableOpacity
key={tab}
style={[styles.tab, activeTab === tab && styles.activeTab]}
onPress={() => setActiveTab(tab)}
>
<Text style={[styles.tabText, activeTab === tab && styles.activeTabText]}>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</Text>
</TouchableOpacity>
))}
</View>
<ScrollView style={styles.content}>
<View style={styles.infoCard}>
<Text style={styles.infoTitle}>Storage Buckets</Text>
<Text style={styles.infoText}>
videos - Workout videos (MP4, MOV){'\n'}
thumbnails - Workout thumbnails (JPG, PNG){'\n'}
avatars - Trainer avatars (JPG, PNG)
</Text>
</View>
<View style={styles.placeholderCard}>
<Text style={styles.placeholderIcon}>🎬</Text>
<Text style={styles.placeholderTitle}>Media Management</Text>
<Text style={styles.placeholderText}>
Upload and manage media files for workouts and trainers.{'\n\n'}
This feature requires file picker integration.{'\n'}
Files will be stored in Supabase Storage.
</Text>
</View>
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
borderBottomWidth: 1,
borderBottomColor: '#1C1C1E',
},
backButton: {
padding: 8,
},
backText: {
color: '#FF6B35',
fontSize: 16,
},
title: {
fontSize: 20,
fontWeight: 'bold',
color: '#fff',
},
uploadButton: {
backgroundColor: '#FF6B35',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
uploadButtonText: {
color: '#000',
fontWeight: 'bold',
},
tabs: {
flexDirection: 'row',
padding: 16,
gap: 8,
},
tab: {
flex: 1,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
backgroundColor: '#1C1C1E',
alignItems: 'center',
},
activeTab: {
backgroundColor: '#FF6B35',
},
tabText: {
color: '#999',
fontWeight: '600',
},
activeTabText: {
color: '#000',
},
content: {
flex: 1,
padding: 16,
},
infoCard: {
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 16,
marginBottom: 16,
},
infoTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
marginBottom: 8,
},
infoText: {
fontSize: 14,
color: '#999',
lineHeight: 20,
},
placeholderCard: {
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 32,
alignItems: 'center',
},
placeholderIcon: {
fontSize: 48,
marginBottom: 16,
},
placeholderTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#fff',
marginBottom: 8,
},
placeholderText: {
fontSize: 14,
color: '#999',
textAlign: 'center',
lineHeight: 20,
},
})

View File

@@ -1,194 +0,0 @@
import { useState } from 'react'
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useTrainers } from '../../src/shared/hooks/useSupabaseData'
import { adminService } from '../../src/admin/services/adminService'
import type { Trainer } from '../../src/shared/types'
export default function AdminTrainersScreen() {
const router = useRouter()
const { trainers, loading, refetch } = useTrainers()
const [deletingId, setDeletingId] = useState<string | null>(null)
const handleDelete = (trainer: Trainer) => {
Alert.alert(
'Delete Trainer',
`Are you sure you want to delete "${trainer.name}"?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
setDeletingId(trainer.id)
try {
await adminService.deleteTrainer(trainer.id)
await refetch()
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to delete')
} finally {
setDeletingId(null)
}
}
},
]
)
}
if (loading) {
return (
<View style={[styles.container, styles.centered]}>
<ActivityIndicator size="large" color="#FF6B35" />
</View>
)
}
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Text style={styles.backText}> Back</Text>
</TouchableOpacity>
<Text style={styles.title}>Trainers</Text>
<TouchableOpacity style={styles.addButton}>
<Text style={styles.addButtonText}>+ Add</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.content}>
{trainers.map((trainer) => (
<View key={trainer.id} style={styles.trainerCard}>
<View style={[styles.colorIndicator, { backgroundColor: trainer.color }]} />
<View style={styles.trainerInfo}>
<Text style={styles.trainerName}>{trainer.name}</Text>
<Text style={styles.trainerMeta}>
{trainer.specialty} {trainer.workoutCount} workouts
</Text>
</View>
<View style={styles.actions}>
<TouchableOpacity style={styles.editButton}>
<Text style={styles.editText}>Edit</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.deleteButton, deletingId === trainer.id && styles.disabledButton]}
onPress={() => handleDelete(trainer)}
disabled={deletingId === trainer.id}
>
<Text style={styles.deleteText}>
{deletingId === trainer.id ? '...' : 'Delete'}
</Text>
</TouchableOpacity>
</View>
</View>
))}
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
centered: {
justifyContent: 'center',
alignItems: 'center',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
borderBottomWidth: 1,
borderBottomColor: '#1C1C1E',
},
backButton: {
padding: 8,
},
backText: {
color: '#FF6B35',
fontSize: 16,
},
title: {
fontSize: 20,
fontWeight: 'bold',
color: '#fff',
},
addButton: {
backgroundColor: '#FF6B35',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
addButtonText: {
color: '#000',
fontWeight: 'bold',
},
content: {
flex: 1,
padding: 16,
},
trainerCard: {
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 16,
marginBottom: 12,
flexDirection: 'row',
alignItems: 'center',
},
colorIndicator: {
width: 12,
height: 12,
borderRadius: 6,
marginRight: 12,
},
trainerInfo: {
flex: 1,
},
trainerName: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
marginBottom: 4,
},
trainerMeta: {
fontSize: 14,
color: '#999',
},
actions: {
flexDirection: 'row',
gap: 8,
},
editButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
editText: {
color: '#5AC8FA',
},
deleteButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
disabledButton: {
opacity: 0.5,
},
deleteText: {
color: '#FF3B30',
},
})

Some files were not shown because too many files have changed in this diff Show More