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.
This commit is contained in:
@@ -2,69 +2,28 @@ import { describe, it, expect } from 'vitest'
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react-native'
|
||||
import { Text } from 'react-native'
|
||||
import { GlassCard, GlassCardElevated, GlassCardInset, GlassCardTinted } from '@/src/shared/components/GlassCard'
|
||||
import { Card, CardAccent, CardTip, GlassCard, GlassCardElevated, GlassCardInset, GlassCardTinted } from '@/src/shared/components/GlassCard'
|
||||
|
||||
describe('GlassCard', () => {
|
||||
describe('Card', () => {
|
||||
it('renders children', () => {
|
||||
render(
|
||||
<GlassCard>
|
||||
<Card>
|
||||
<Text testID="child">Hello</Text>
|
||||
</GlassCard>
|
||||
</Card>
|
||||
)
|
||||
expect(screen.getByTestId('child')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders BlurView when hasBlur is true (default)', () => {
|
||||
render(
|
||||
<GlassCard>
|
||||
<Text>Content</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
expect(screen.getByTestId('blur-view')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not render BlurView when hasBlur is false', () => {
|
||||
render(
|
||||
<GlassCard hasBlur={false}>
|
||||
<Text>Content</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
expect(screen.queryByTestId('blur-view')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders with custom blurIntensity', () => {
|
||||
render(
|
||||
<GlassCard blurIntensity={80}>
|
||||
<Text>Content</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
const blurView = screen.getByTestId('blur-view')
|
||||
expect(blurView.props.intensity).toBe(80)
|
||||
})
|
||||
|
||||
it('uses theme blurMedium when blurIntensity is not provided', () => {
|
||||
render(
|
||||
<GlassCard>
|
||||
<Text>Content</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
const blurView = screen.getByTestId('blur-view')
|
||||
// from mock: colors.glass.blurMedium = 40
|
||||
expect(blurView.props.intensity).toBe(40)
|
||||
})
|
||||
|
||||
it('applies custom style prop to root container', () => {
|
||||
const customStyle = { padding: 20 }
|
||||
const { toJSON } = render(
|
||||
<GlassCard style={customStyle}>
|
||||
<Card style={customStyle}>
|
||||
<Text>Content</Text>
|
||||
</GlassCard>
|
||||
</Card>
|
||||
)
|
||||
const tree = toJSON()
|
||||
// Root View should have the custom style merged into its style array
|
||||
const rootStyle = tree?.props?.style
|
||||
expect(rootStyle).toBeDefined()
|
||||
// Style is an array — flatten and check custom style is present
|
||||
const flatStyles = Array.isArray(rootStyle) ? rootStyle : [rootStyle]
|
||||
const hasPadding = flatStyles.some(
|
||||
(s: any) => s && typeof s === 'object' && s.padding === 20
|
||||
@@ -73,82 +32,69 @@ describe('GlassCard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('GlassCard variants', () => {
|
||||
it('renders base variant (snapshot)', () => {
|
||||
describe('Card variants', () => {
|
||||
it('renders default variant (snapshot)', () => {
|
||||
const { toJSON } = render(
|
||||
<GlassCard>
|
||||
<Text>Base</Text>
|
||||
</GlassCard>
|
||||
<Card>
|
||||
<Text>Default</Text>
|
||||
</Card>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders elevated variant (snapshot)', () => {
|
||||
it('renders accent variant (snapshot)', () => {
|
||||
const { toJSON } = render(
|
||||
<GlassCard variant="elevated">
|
||||
<Text>Elevated</Text>
|
||||
</GlassCard>
|
||||
<CardAccent>
|
||||
<Text>Accent</Text>
|
||||
</CardAccent>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders inset variant (snapshot)', () => {
|
||||
it('renders tip variant (snapshot)', () => {
|
||||
const { toJSON } = render(
|
||||
<GlassCard variant="inset">
|
||||
<Text>Inset</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders tinted variant (snapshot)', () => {
|
||||
const { toJSON } = render(
|
||||
<GlassCard variant="tinted">
|
||||
<Text>Tinted</Text>
|
||||
</GlassCard>
|
||||
<CardTip>
|
||||
<Text>Tip</Text>
|
||||
</CardTip>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GlassCard presets', () => {
|
||||
it('GlassCardElevated renders with blur and children', () => {
|
||||
const { getByTestId } = render(
|
||||
describe('Backward-compatible aliases', () => {
|
||||
it('GlassCard renders children', () => {
|
||||
render(
|
||||
<GlassCard>
|
||||
<Text testID="bc-child">Backward compat</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
expect(screen.getByTestId('bc-child')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('GlassCardElevated renders children', () => {
|
||||
render(
|
||||
<GlassCardElevated>
|
||||
<Text testID="elevated-child">Elevated</Text>
|
||||
</GlassCardElevated>
|
||||
)
|
||||
expect(getByTestId('elevated-child')).toBeTruthy()
|
||||
expect(getByTestId('blur-view')).toBeTruthy()
|
||||
expect(screen.getByTestId('elevated-child')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('GlassCardInset renders WITHOUT blur (hasBlur=false)', () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
it('GlassCardInset renders children', () => {
|
||||
render(
|
||||
<GlassCardInset>
|
||||
<Text testID="inset-child">Inset</Text>
|
||||
</GlassCardInset>
|
||||
)
|
||||
expect(getByTestId('inset-child')).toBeTruthy()
|
||||
// GlassCardInset passes hasBlur={false} — this is the key behavioral assertion
|
||||
expect(queryByTestId('blur-view')).toBeNull()
|
||||
expect(screen.getByTestId('inset-child')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('GlassCardTinted renders with blur', () => {
|
||||
const { getByTestId } = render(
|
||||
it('GlassCardTinted renders children', () => {
|
||||
render(
|
||||
<GlassCardTinted>
|
||||
<Text testID="tinted-child">Tinted</Text>
|
||||
</GlassCardTinted>
|
||||
)
|
||||
expect(getByTestId('tinted-child')).toBeTruthy()
|
||||
expect(getByTestId('blur-view')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('GlassCardElevated snapshot', () => {
|
||||
const { toJSON } = render(
|
||||
<GlassCardElevated>
|
||||
<Text>Elevated preset</Text>
|
||||
</GlassCardElevated>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
expect(screen.getByTestId('tinted-child')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -116,10 +116,10 @@ describe('dataService', () => {
|
||||
|
||||
describe('getTrainerById', () => {
|
||||
it('should return trainer by id', async () => {
|
||||
const trainer = await dataService.getTrainerById('emma')
|
||||
const trainer = await dataService.getTrainerById('felia')
|
||||
|
||||
expect(trainer).toBeDefined()
|
||||
expect(trainer?.id).toBe('emma')
|
||||
expect(trainer?.id).toBe('felia')
|
||||
})
|
||||
|
||||
it('should return undefined for non-existent trainer', async () => {
|
||||
|
||||
@@ -3,8 +3,8 @@ import { TRAINERS } from '../../shared/data/trainers'
|
||||
|
||||
describe('trainers data', () => {
|
||||
describe('TRAINERS structure', () => {
|
||||
it('should have exactly 5 trainers', () => {
|
||||
expect(TRAINERS).toHaveLength(5)
|
||||
it('should have exactly 2 trainers', () => {
|
||||
expect(TRAINERS).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should have all required properties', () => {
|
||||
@@ -44,62 +44,42 @@ describe('trainers data', () => {
|
||||
})
|
||||
|
||||
describe('specific trainers', () => {
|
||||
it('should have Emma as first trainer', () => {
|
||||
expect(TRAINERS[0].id).toBe('emma')
|
||||
expect(TRAINERS[0].name).toBe('Emma')
|
||||
expect(TRAINERS[0].specialty).toBe('Full Body')
|
||||
it('should have Félia as first trainer', () => {
|
||||
expect(TRAINERS[0].id).toBe('felia')
|
||||
expect(TRAINERS[0].name).toBe('Félia')
|
||||
expect(TRAINERS[0].gender).toBe('female')
|
||||
expect(TRAINERS[0].specialty).toBe('Core')
|
||||
})
|
||||
|
||||
it('should have Jake as second trainer', () => {
|
||||
expect(TRAINERS[1].id).toBe('jake')
|
||||
expect(TRAINERS[1].name).toBe('Jake')
|
||||
it('should have Félix as second trainer', () => {
|
||||
expect(TRAINERS[1].id).toBe('felix')
|
||||
expect(TRAINERS[1].name).toBe('Félix')
|
||||
expect(TRAINERS[1].gender).toBe('male')
|
||||
expect(TRAINERS[1].specialty).toBe('Strength')
|
||||
})
|
||||
})
|
||||
|
||||
it('should have Mia as third trainer', () => {
|
||||
expect(TRAINERS[2].id).toBe('mia')
|
||||
expect(TRAINERS[2].name).toBe('Mia')
|
||||
expect(TRAINERS[2].specialty).toBe('Core')
|
||||
})
|
||||
|
||||
it('should have Alex as fourth trainer', () => {
|
||||
expect(TRAINERS[3].id).toBe('alex')
|
||||
expect(TRAINERS[3].name).toBe('Alex')
|
||||
expect(TRAINERS[3].specialty).toBe('Cardio')
|
||||
})
|
||||
|
||||
it('should have Sofia as fifth trainer', () => {
|
||||
expect(TRAINERS[4].id).toBe('sofia')
|
||||
expect(TRAINERS[4].name).toBe('Sofia')
|
||||
expect(TRAINERS[4].specialty).toBe('Recovery')
|
||||
describe('gender distribution', () => {
|
||||
it('should have exactly 1 male and 1 female trainer', () => {
|
||||
const males = TRAINERS.filter(t => t.gender === 'male')
|
||||
const females = TRAINERS.filter(t => t.gender === 'female')
|
||||
expect(males).toHaveLength(1)
|
||||
expect(females).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('specialty coverage', () => {
|
||||
it('should cover all major workout types', () => {
|
||||
it('should cover core workout types', () => {
|
||||
const specialties = TRAINERS.map(t => t.specialty)
|
||||
expect(specialties).toContain('Full Body')
|
||||
expect(specialties).toContain('Strength')
|
||||
expect(specialties).toContain('Core')
|
||||
expect(specialties).toContain('Cardio')
|
||||
expect(specialties).toContain('Recovery')
|
||||
expect(specialties).toContain('Strength')
|
||||
})
|
||||
})
|
||||
|
||||
describe('workout distribution', () => {
|
||||
it('should have Emma with most workouts', () => {
|
||||
const emma = TRAINERS.find(t => t.id === 'emma')
|
||||
expect(emma!.workoutCount).toBe(15)
|
||||
})
|
||||
|
||||
it('should have Sofia with fewest workouts', () => {
|
||||
const sofia = TRAINERS.find(t => t.id === 'sofia')
|
||||
expect(sofia!.workoutCount).toBe(5)
|
||||
})
|
||||
|
||||
it('should have total workout count of 50', () => {
|
||||
it('should have total workout count of 30', () => {
|
||||
const total = TRAINERS.reduce((sum, t) => sum + t.workoutCount, 0)
|
||||
expect(total).toBe(50)
|
||||
expect(total).toBe(30)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -170,7 +170,7 @@ describe('workouts data', () => {
|
||||
})
|
||||
|
||||
describe('trainer assignments', () => {
|
||||
const validTrainers = ['emma', 'jake', 'alex', 'sofia', 'mia']
|
||||
const validTrainers = ['felia', 'felix']
|
||||
|
||||
it('should only have valid trainer IDs', () => {
|
||||
WORKOUTS.forEach((workout) => {
|
||||
|
||||
@@ -85,67 +85,66 @@ vi.mock('expo-linking', () => ({
|
||||
|
||||
const mockThemeColors = {
|
||||
bg: {
|
||||
base: '#000000',
|
||||
surface: '#1C1C1E',
|
||||
elevated: '#2C2C2E',
|
||||
base: '#0D1B2A',
|
||||
surface: '#112240',
|
||||
elevated: '#1A3050',
|
||||
overlay1: 'rgba(168,178,216,0.06)',
|
||||
overlay2: 'rgba(168,178,216,0.10)',
|
||||
overlay3: 'rgba(168,178,216,0.15)',
|
||||
scrim: 'rgba(0,0,0,0.6)',
|
||||
},
|
||||
text: {
|
||||
primary: '#FFFFFF',
|
||||
secondary: '#8E8E93',
|
||||
tertiary: '#636366',
|
||||
primary: '#E6F1FF',
|
||||
secondary: '#A8B2D8',
|
||||
tertiary: '#8892B0',
|
||||
muted: '#8892B0',
|
||||
hint: '#8892B0',
|
||||
disabled: '#3A3A3C',
|
||||
},
|
||||
glass: {
|
||||
base: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
surface: {
|
||||
default: {
|
||||
backgroundColor: '#112240',
|
||||
borderColor: 'rgba(168,178,216,0.15)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
elevated: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||
accent: {
|
||||
backgroundColor: 'rgba(0,200,150,0.05)',
|
||||
borderColor: 'rgba(0,200,150,0.35)',
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
tip: {
|
||||
backgroundColor: 'rgba(255,138,92,0.12)',
|
||||
borderColor: '#FF8A5C',
|
||||
borderWidth: 1,
|
||||
},
|
||||
inset: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.2)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
tinted: {
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.1)',
|
||||
borderColor: 'rgba(255, 107, 53, 0.2)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
blurTint: 'dark',
|
||||
blurMedium: 40,
|
||||
},
|
||||
shadow: {
|
||||
sm: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.2, shadowRadius: 2 },
|
||||
md: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.25, shadowRadius: 4 },
|
||||
lg: { shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8 },
|
||||
border: {
|
||||
dim: 'rgba(168,178,216,0.15)',
|
||||
hover: 'rgba(168,178,216,0.25)',
|
||||
brand: 'rgba(0,200,150,0.35)',
|
||||
},
|
||||
gradients: {
|
||||
videoOverlay: ['transparent', 'rgba(0,0,0,0.8)'],
|
||||
videoTop: ['rgba(0,0,0,0.5)', 'transparent'],
|
||||
},
|
||||
colorScheme: 'dark' as const,
|
||||
statusBarStyle: 'light' as const,
|
||||
}
|
||||
|
||||
vi.mock('@/src/shared/theme', () => ({
|
||||
useThemeColors: () => mockThemeColors,
|
||||
ThemeProvider: ({ children }: any) => children,
|
||||
BRAND: {
|
||||
PRIMARY: '#FF6B35',
|
||||
PRIMARY_DARK: '#E55A2B',
|
||||
},
|
||||
GRADIENTS: {
|
||||
VIDEO_OVERLAY: ['transparent', 'rgba(0,0,0,0.8)'],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/src/shared/constants/borderRadius', () => ({
|
||||
RADIUS: {
|
||||
SM: 8,
|
||||
MD: 12,
|
||||
LG: 16,
|
||||
XL: 20,
|
||||
NONE: 0,
|
||||
SM: 4,
|
||||
MD: 8,
|
||||
LG: 12,
|
||||
XL: 16,
|
||||
PILL: 9999,
|
||||
FULL: 9999,
|
||||
GLASS_CARD: 24,
|
||||
GLASS_BUTTON: 14,
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
201
src/__tests__/stores/tabataProgramStore.test.ts
Normal file
201
src/__tests__/stores/tabataProgramStore.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useTabataProgramStore } from '../../shared/stores/tabataProgramStore'
|
||||
import type { TabataProgramId } from '../../shared/types/program'
|
||||
|
||||
const PROGRAM_IDS: TabataProgramId[] = ['debutant', 'intermediaire', 'avance', 'bureau']
|
||||
|
||||
const resetStore = () => {
|
||||
const initial: Record<TabataProgramId, any> = {} as any
|
||||
for (const id of PROGRAM_IDS) {
|
||||
initial[id] = {
|
||||
programId: id,
|
||||
currentWeek: 1,
|
||||
currentSessionIndex: 0,
|
||||
completedSessionIds: [],
|
||||
isProgramCompleted: false,
|
||||
startDate: undefined,
|
||||
lastSessionDate: undefined,
|
||||
}
|
||||
}
|
||||
useTabataProgramStore.setState({
|
||||
selectedProgramId: null,
|
||||
programsProgress: initial,
|
||||
})
|
||||
}
|
||||
|
||||
describe('tabataProgramStore', () => {
|
||||
beforeEach(() => {
|
||||
resetStore()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should have no selected program', () => {
|
||||
expect(useTabataProgramStore.getState().selectedProgramId).toBeNull()
|
||||
})
|
||||
|
||||
it('should have initial progress for all 4 programs', () => {
|
||||
const progress = useTabataProgramStore.getState().programsProgress
|
||||
for (const id of PROGRAM_IDS) {
|
||||
expect(progress[id]).toBeDefined()
|
||||
expect(progress[id].completedSessionIds).toEqual([])
|
||||
expect(progress[id].isProgramCompleted).toBe(false)
|
||||
expect(progress[id].currentWeek).toBe(1)
|
||||
expect(progress[id].currentSessionIndex).toBe(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectProgram', () => {
|
||||
it('should set selectedProgramId and startDate on first selection', () => {
|
||||
useTabataProgramStore.getState().selectProgram('debutant')
|
||||
const state = useTabataProgramStore.getState()
|
||||
expect(state.selectedProgramId).toBe('debutant')
|
||||
expect(state.programsProgress.debutant.startDate).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not overwrite startDate on re-selection after progress', () => {
|
||||
useTabataProgramStore.getState().selectProgram('debutant')
|
||||
const firstDate = useTabataProgramStore.getState().programsProgress.debutant.startDate
|
||||
|
||||
// Simulate some progress
|
||||
useTabataProgramStore.setState(s => ({
|
||||
programsProgress: {
|
||||
...s.programsProgress,
|
||||
debutant: {
|
||||
...s.programsProgress.debutant,
|
||||
completedSessionIds: ['deb-w1-s1'],
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Re-select
|
||||
useTabataProgramStore.getState().selectProgram('debutant')
|
||||
expect(useTabataProgramStore.getState().selectedProgramId).toBe('debutant')
|
||||
// startDate should remain unchanged
|
||||
expect(useTabataProgramStore.getState().programsProgress.debutant.startDate).toBe(firstDate)
|
||||
})
|
||||
})
|
||||
|
||||
describe('completeSession', () => {
|
||||
it('should add session ID to completed list', () => {
|
||||
useTabataProgramStore.getState().completeSession('debutant', 'deb-w1-s1')
|
||||
const progress = useTabataProgramStore.getState().programsProgress.debutant
|
||||
expect(progress.completedSessionIds).toContain('deb-w1-s1')
|
||||
expect(progress.lastSessionDate).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not duplicate session IDs', () => {
|
||||
useTabataProgramStore.getState().completeSession('debutant', 'deb-w1-s1')
|
||||
useTabataProgramStore.getState().completeSession('debutant', 'deb-w1-s1')
|
||||
const progress = useTabataProgramStore.getState().programsProgress.debutant
|
||||
expect(progress.completedSessionIds.filter(id => id === 'deb-w1-s1')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should advance session index within same week', () => {
|
||||
useTabataProgramStore.getState().completeSession('debutant', 'deb-w1-s1')
|
||||
const progress = useTabataProgramStore.getState().programsProgress.debutant
|
||||
expect(progress.currentSessionIndex).toBe(1)
|
||||
expect(progress.currentWeek).toBe(1)
|
||||
})
|
||||
|
||||
it('should ignore unknown program IDs', () => {
|
||||
// Should not throw
|
||||
useTabataProgramStore.getState().completeSession('nonexistent' as TabataProgramId, 'x')
|
||||
// State unchanged
|
||||
expect(useTabataProgramStore.getState().programsProgress.debutant.completedSessionIds).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetProgram', () => {
|
||||
it('should reset progress to initial state', () => {
|
||||
useTabataProgramStore.getState().selectProgram('debutant')
|
||||
useTabataProgramStore.getState().completeSession('debutant', 'deb-w1-s1')
|
||||
useTabataProgramStore.getState().resetProgram('debutant')
|
||||
|
||||
const state = useTabataProgramStore.getState()
|
||||
expect(state.selectedProgramId).toBeNull()
|
||||
expect(state.programsProgress.debutant.completedSessionIds).toEqual([])
|
||||
expect(state.programsProgress.debutant.startDate).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not affect other programs when resetting one', () => {
|
||||
useTabataProgramStore.getState().selectProgram('intermediaire')
|
||||
useTabataProgramStore.getState().completeSession('intermediaire', 'int-w1-s1')
|
||||
useTabataProgramStore.getState().resetProgram('debutant')
|
||||
|
||||
const state = useTabataProgramStore.getState()
|
||||
expect(state.selectedProgramId).toBe('intermediaire')
|
||||
expect(state.programsProgress.intermediaire.completedSessionIds).toContain('int-w1-s1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('changeProgram', () => {
|
||||
it('should change selected program without resetting progress', () => {
|
||||
useTabataProgramStore.getState().selectProgram('debutant')
|
||||
useTabataProgramStore.getState().completeSession('debutant', 'deb-w1-s1')
|
||||
useTabataProgramStore.getState().changeProgram('intermediaire')
|
||||
|
||||
const state = useTabataProgramStore.getState()
|
||||
expect(state.selectedProgramId).toBe('intermediaire')
|
||||
expect(state.programsProgress.debutant.completedSessionIds).toContain('deb-w1-s1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getters', () => {
|
||||
it('getCurrentSession should return first session initially', () => {
|
||||
const session = useTabataProgramStore.getState().getCurrentSession('debutant')
|
||||
expect(session).not.toBeNull()
|
||||
expect(session?.id).toMatch(/^deb-/)
|
||||
})
|
||||
|
||||
it('getCurrentSession should return null for unknown program', () => {
|
||||
const session = useTabataProgramStore.getState().getCurrentSession('nonexistent' as TabataProgramId)
|
||||
expect(session).toBeNull()
|
||||
})
|
||||
|
||||
it('getProgramCompletion should return 0 initially', () => {
|
||||
expect(useTabataProgramStore.getState().getProgramCompletion('debutant')).toBe(0)
|
||||
})
|
||||
|
||||
it('getTotalSessionsCompleted should sum across all programs', () => {
|
||||
useTabataProgramStore.getState().completeSession('debutant', 'deb-w1-s1')
|
||||
useTabataProgramStore.getState().completeSession('intermediaire', 'int-w1-s1')
|
||||
expect(useTabataProgramStore.getState().getTotalSessionsCompleted()).toBe(2)
|
||||
})
|
||||
|
||||
it('getProgramStatus should return not-started initially', () => {
|
||||
expect(useTabataProgramStore.getState().getProgramStatus('debutant')).toBe('not-started')
|
||||
})
|
||||
|
||||
it('getProgramStatus should return in-progress after completing a session', () => {
|
||||
useTabataProgramStore.getState().completeSession('debutant', 'deb-w1-s1')
|
||||
expect(useTabataProgramStore.getState().getProgramStatus('debutant')).toBe('in-progress')
|
||||
})
|
||||
|
||||
it('isWeekUnlocked should return true for week 1', () => {
|
||||
expect(useTabataProgramStore.getState().isWeekUnlocked('debutant', 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('isWeekUnlocked should return false for week 2 initially', () => {
|
||||
expect(useTabataProgramStore.getState().isWeekUnlocked('debutant', 2)).toBe(false)
|
||||
})
|
||||
|
||||
it('getProgram should return program data', () => {
|
||||
const program = useTabataProgramStore.getState().getProgram('debutant')
|
||||
expect(program).toBeDefined()
|
||||
expect(program?.id).toBe('debutant')
|
||||
})
|
||||
|
||||
it('getRecommendedNext should return current session of selected program', () => {
|
||||
useTabataProgramStore.getState().selectProgram('debutant')
|
||||
const rec = useTabataProgramStore.getState().getRecommendedNext()
|
||||
expect(rec).not.toBeNull()
|
||||
expect(rec?.programId).toBe('debutant')
|
||||
})
|
||||
|
||||
it('getRecommendedNext should return null when no programs started', () => {
|
||||
const rec = useTabataProgramStore.getState().getRecommendedNext()
|
||||
expect(rec).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
189
src/__tests__/stores/workoutProgramStore.test.ts
Normal file
189
src/__tests__/stores/workoutProgramStore.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useWorkoutProgramStore } from '../../shared/stores/workoutProgramStore'
|
||||
import type { WorkoutProgram } from '../../shared/types/workoutProgram'
|
||||
|
||||
const resetStore = () => {
|
||||
useWorkoutProgramStore.setState({ completions: {} })
|
||||
}
|
||||
|
||||
const mockPrograms: WorkoutProgram[] = [
|
||||
{
|
||||
id: 'prog-1',
|
||||
title: 'Upper Body Basics',
|
||||
description: 'Upper body workout',
|
||||
bodyZone: 'upper-body',
|
||||
level: 'Beginner',
|
||||
isFree: true,
|
||||
musicVibe: 'electronic',
|
||||
estimatedDuration: 12,
|
||||
estimatedCalories: 100,
|
||||
icon: 'dumbbell',
|
||||
accentColor: '#FF6B35',
|
||||
sortOrder: 1,
|
||||
tabatas: [],
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: 'prog-2',
|
||||
title: 'Lower Body Burn',
|
||||
description: 'Lower body workout',
|
||||
bodyZone: 'lower-body',
|
||||
level: 'Intermediate',
|
||||
isFree: false,
|
||||
musicVibe: 'hip-hop',
|
||||
estimatedDuration: 15,
|
||||
estimatedCalories: 150,
|
||||
icon: 'figure.walk',
|
||||
accentColor: '#5AC8FA',
|
||||
sortOrder: 2,
|
||||
tabatas: [],
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: 'prog-3',
|
||||
title: 'Full Body Advanced',
|
||||
description: 'Full body workout',
|
||||
bodyZone: 'full-body',
|
||||
level: 'Advanced',
|
||||
isFree: false,
|
||||
musicVibe: 'rock',
|
||||
estimatedDuration: 20,
|
||||
estimatedCalories: 200,
|
||||
icon: 'bolt',
|
||||
accentColor: '#30D158',
|
||||
sortOrder: 3,
|
||||
tabatas: [],
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
},
|
||||
]
|
||||
|
||||
describe('workoutProgramStore', () => {
|
||||
beforeEach(() => {
|
||||
resetStore()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should have empty completions', () => {
|
||||
expect(useWorkoutProgramStore.getState().completions).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('completeProgram', () => {
|
||||
it('should mark entire program as completed when no tabataPosition', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1')
|
||||
const state = useWorkoutProgramStore.getState()
|
||||
expect(state.completions['prog-1']).toBeDefined()
|
||||
expect(state.completions['prog-1'].tabatasCompleted).toEqual([1, 2, 3])
|
||||
expect(state.completions['prog-1'].completedAt).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should mark specific tabata as completed', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 1)
|
||||
const state = useWorkoutProgramStore.getState()
|
||||
expect(state.completions['prog-1'].tabatasCompleted).toEqual([1])
|
||||
})
|
||||
|
||||
it('should accumulate tabata completions', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 1)
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 2)
|
||||
const state = useWorkoutProgramStore.getState()
|
||||
expect(state.completions['prog-1'].tabatasCompleted).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('should not duplicate tabata positions', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 1)
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 1)
|
||||
const state = useWorkoutProgramStore.getState()
|
||||
expect(state.completions['prog-1'].tabatasCompleted).toEqual([1])
|
||||
})
|
||||
|
||||
it('should set completedAt when all 3 tabatas done', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 1)
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 2)
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 3)
|
||||
const state = useWorkoutProgramStore.getState()
|
||||
expect(state.completions['prog-1'].completedAt).toBeTruthy()
|
||||
expect(state.completions['prog-1'].tabatasCompleted).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetProgram', () => {
|
||||
it('should remove program completion', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1')
|
||||
useWorkoutProgramStore.getState().resetProgram('prog-1')
|
||||
expect(useWorkoutProgramStore.getState().completions['prog-1']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not affect other programs', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1')
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-2')
|
||||
useWorkoutProgramStore.getState().resetProgram('prog-1')
|
||||
expect(useWorkoutProgramStore.getState().completions['prog-2']).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isProgramCompleted', () => {
|
||||
it('should return false for uncompleted program', () => {
|
||||
expect(useWorkoutProgramStore.getState().isProgramCompleted('prog-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true when all 3 tabatas completed', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1')
|
||||
expect(useWorkoutProgramStore.getState().isProgramCompleted('prog-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when only 2 tabatas completed', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 1)
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 2)
|
||||
expect(useWorkoutProgramStore.getState().isProgramCompleted('prog-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCompletedCount', () => {
|
||||
it('should return 0 initially', () => {
|
||||
expect(useWorkoutProgramStore.getState().getCompletedCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('should count fully completed programs', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1')
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-2')
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-3', 1) // partial
|
||||
expect(useWorkoutProgramStore.getState().getCompletedCount()).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRecommendedNext', () => {
|
||||
it('should recommend first incomplete program sorted by level', () => {
|
||||
const rec = useWorkoutProgramStore.getState().getRecommendedNext(mockPrograms)
|
||||
expect(rec).not.toBeNull()
|
||||
expect(rec?.id).toBe('prog-1') // Beginner first
|
||||
})
|
||||
|
||||
it('should skip completed programs', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1')
|
||||
const rec = useWorkoutProgramStore.getState().getRecommendedNext(mockPrograms)
|
||||
expect(rec?.id).toBe('prog-2') // Intermediate
|
||||
})
|
||||
|
||||
it('should return null when all completed', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1')
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-2')
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-3')
|
||||
expect(useWorkoutProgramStore.getState().getRecommendedNext(mockPrograms)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTabatasCompleted', () => {
|
||||
it('should return empty array for unknown program', () => {
|
||||
expect(useWorkoutProgramStore.getState().getTabatasCompleted('unknown')).toEqual([])
|
||||
})
|
||||
|
||||
it('should return completed tabata positions', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 2)
|
||||
expect(useWorkoutProgramStore.getState().getTabatasCompleted('prog-1')).toEqual([2])
|
||||
})
|
||||
})
|
||||
})
|
||||
32
src/__tests__/utils/color.test.ts
Normal file
32
src/__tests__/utils/color.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { withOpacity } from '../../shared/utils/color'
|
||||
|
||||
describe('withOpacity', () => {
|
||||
it('should convert 6-digit hex with opacity', () => {
|
||||
expect(withOpacity('#FF6B35', 0.5)).toBe('rgba(255,107,53,0.5)')
|
||||
})
|
||||
|
||||
it('should convert 3-digit hex with opacity', () => {
|
||||
expect(withOpacity('#FFF', 1)).toBe('rgba(255,255,255,1)')
|
||||
})
|
||||
|
||||
it('should handle hex without # prefix', () => {
|
||||
expect(withOpacity('000000', 0)).toBe('rgba(0,0,0,0)')
|
||||
})
|
||||
|
||||
it('should handle 3-digit hex without # prefix', () => {
|
||||
expect(withOpacity('F00', 0.8)).toBe('rgba(255,0,0,0.8)')
|
||||
})
|
||||
|
||||
it('should handle pure black', () => {
|
||||
expect(withOpacity('#000000', 1)).toBe('rgba(0,0,0,1)')
|
||||
})
|
||||
|
||||
it('should handle pure white', () => {
|
||||
expect(withOpacity('#FFFFFF', 0.12)).toBe('rgba(255,255,255,0.12)')
|
||||
})
|
||||
|
||||
it('should handle lowercase hex', () => {
|
||||
expect(withOpacity('#ff6b35', 0.5)).toBe('rgba(255,107,53,0.5)')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||
import { adminService } from '../services/adminService'
|
||||
import { logger } from '@/src/shared/utils/logger'
|
||||
|
||||
interface AdminAuthContextType {
|
||||
isAuthenticated: boolean
|
||||
@@ -29,7 +30,7 @@ export function AdminAuthProvider({ children }: { children: ReactNode }) {
|
||||
setIsAdmin(adminStatus)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error)
|
||||
logger.error('Auth check failed:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
15
src/features/player/CLAUDE.md
Normal file
15
src/features/player/CLAUDE.md
Normal file
@@ -0,0 +1,15 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 13, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6298 | 11:39 PM | 🟣 | YouTube music system fully integrated with workout timers - database-driven architecture replaces storage buckets | ~617 |
|
||||
| #6282 | 11:06 PM | 🟣 | Music playback disabled during warm-up phase in Kine sessions | ~293 |
|
||||
| #6275 | 10:53 PM | 🔴 | React hook execution order fixed in KinePlayerScreen | ~327 |
|
||||
| #6272 | " | 🟣 | NowPlaying component integrated into KinePlayerScreen UI | ~368 |
|
||||
| #6271 | 10:52 PM | 🟣 | useMusicPlayer hook added to KinePlayerScreen for music synchronization | ~359 |
|
||||
</claude-mem-context>
|
||||
354
src/features/player/TabataPlayerScreen.tsx
Normal file
354
src/features/player/TabataPlayerScreen.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Tabata Player Screen
|
||||
* Handles multi-block tabata sessions with warmup, blocks, inter-block rest, cooldown
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect, useCallback, useState } from 'react'
|
||||
import {
|
||||
View, Text, StyleSheet, Pressable, Animated, StatusBar,
|
||||
} from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useKeepAwake } from 'expo-keep-awake'
|
||||
|
||||
import { useTabataTimer } from '@/src/shared/hooks/useTabataTimer'
|
||||
import { useHaptics } from '@/src/shared/hooks/useHaptics'
|
||||
import { useAudio } from '@/src/shared/hooks/useAudio'
|
||||
import { useMusicPlayer } from '@/src/shared/hooks/useMusicPlayer'
|
||||
import { useActivityStore } from '@/src/shared/stores'
|
||||
import { useTabataProgramStore } from '@/src/shared/stores/tabataProgramStore'
|
||||
import { useWorkoutProgramStore } from '@/src/shared/stores/workoutProgramStore'
|
||||
import { getSessionProgramId } from '@/src/shared/services/access'
|
||||
import { track } from '@/src/shared/services/analytics'
|
||||
import type { TabataSession } from '@/src/shared/types/program'
|
||||
import { PHASE_COLORS, darkColors } from '@/src/shared/theme'
|
||||
import { TYPOGRAPHY, FONT_FAMILY } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { GREEN, NAVY, BORDER_COLORS, TEXT, PHASE, AMBER } from '@/src/shared/constants/colors'
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
|
||||
import { TimerRing, PhaseIndicator, RoundIndicator, PlayerControls, StatsOverlay, CoachEncouragement, NowPlaying } from '@/src/features/player'
|
||||
import { TabataTip } from '@/src/features/player/components/TabataTip'
|
||||
import { BlockIndicator } from '@/src/features/player/components/BlockIndicator'
|
||||
import { WarmupOverlay } from '@/src/features/player/components/WarmupOverlay'
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const TABATA_PHASE_COLORS: Record<string, string> = {
|
||||
WARMUP: PHASE.PREP,
|
||||
WORK: PHASE.WORK,
|
||||
REST: PHASE.REST,
|
||||
INTER_BLOCK_REST: AMBER[500],
|
||||
COOLDOWN: GREEN[500],
|
||||
COMPLETE: GREEN[500],
|
||||
}
|
||||
|
||||
interface TabataPlayerScreenProps {
|
||||
session: TabataSession
|
||||
}
|
||||
|
||||
export function TabataPlayerScreen({ session }: TabataPlayerScreenProps) {
|
||||
useKeepAwake()
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const haptics = useHaptics()
|
||||
const audio = useAudio()
|
||||
const addWorkoutResult = useActivityStore(s => s.addWorkoutResult)
|
||||
const completeSession = useTabataProgramStore(s => s.completeSession)
|
||||
const completeProgram = useWorkoutProgramStore(s => s.completeProgram)
|
||||
|
||||
const timer = useTabataTimer(session)
|
||||
const music = useMusicPlayer({
|
||||
vibe: session.musicVibe ?? 'electronic',
|
||||
isPlaying: timer.isRunning && !timer.isPaused && timer.phase !== 'WARMUP',
|
||||
})
|
||||
const [showControls, setShowControls] = useState(true)
|
||||
const timerScaleAnim = useRef(new Animated.Value(0.8)).current
|
||||
|
||||
const phaseColor = TABATA_PHASE_COLORS[timer.phase] ?? PHASE.WORK
|
||||
|
||||
// ─── Actions ─────────────────────────────────────────────────
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
timer.start()
|
||||
haptics.buttonTap()
|
||||
track('tabata_session_started', { session_id: session.id, blocks: session.blocks.length })
|
||||
}, [timer, haptics, session])
|
||||
|
||||
const togglePause = useCallback(() => {
|
||||
if (timer.isPaused) {
|
||||
timer.resume()
|
||||
track('tabata_session_resumed', { session_id: session.id })
|
||||
} else {
|
||||
timer.pause()
|
||||
track('tabata_session_paused', { session_id: session.id })
|
||||
}
|
||||
haptics.selection()
|
||||
}, [timer, haptics, session])
|
||||
|
||||
const stopWorkout = useCallback(() => {
|
||||
haptics.phaseChange()
|
||||
timer.stop()
|
||||
router.back()
|
||||
}, [router, timer, haptics])
|
||||
|
||||
const completeWorkout = useCallback(() => {
|
||||
haptics.workoutComplete()
|
||||
track('tabata_session_completed', {
|
||||
session_id: session.id,
|
||||
calories: timer.calories,
|
||||
total_rounds: timer.totalRounds,
|
||||
blocks: timer.totalBlocks,
|
||||
})
|
||||
addWorkoutResult({
|
||||
id: Date.now().toString(),
|
||||
workoutId: session.id,
|
||||
completedAt: Date.now(),
|
||||
calories: timer.calories,
|
||||
durationMinutes: session.totalDuration,
|
||||
rounds: timer.totalRounds,
|
||||
completionRate: 1,
|
||||
})
|
||||
// Mark session complete in program store
|
||||
if (session.id.startsWith('wp-')) {
|
||||
const programId = session.id.slice(3)
|
||||
completeProgram(programId)
|
||||
} else {
|
||||
const programId = getSessionProgramId(session.id)
|
||||
if (programId) {
|
||||
completeSession(programId, session.id)
|
||||
}
|
||||
}
|
||||
router.replace(`/complete/${session.id}`)
|
||||
}, [router, session, timer, haptics, addWorkoutResult, completeSession, completeProgram])
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
timer.skip()
|
||||
haptics.selection()
|
||||
}, [timer, haptics])
|
||||
|
||||
// ─── Animations & side-effects ───────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
Animated.spring(timerScaleAnim, {
|
||||
toValue: 1, friction: 6, tension: 100, useNativeDriver: true,
|
||||
}).start()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
timerScaleAnim.setValue(0.9)
|
||||
Animated.spring(timerScaleAnim, {
|
||||
toValue: 1, friction: 4, tension: 150, useNativeDriver: true,
|
||||
}).start()
|
||||
haptics.phaseChange()
|
||||
if (timer.phase === 'COMPLETE') {
|
||||
audio.workoutComplete()
|
||||
} else if (timer.isRunning) {
|
||||
audio.phaseStart()
|
||||
}
|
||||
}, [timer.phase])
|
||||
|
||||
useEffect(() => {
|
||||
if (timer.isRunning && timer.timeRemaining <= 3 && timer.timeRemaining > 0) {
|
||||
audio.countdownBeep()
|
||||
haptics.countdownTick()
|
||||
}
|
||||
}, [timer.timeRemaining])
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────────
|
||||
|
||||
const isWarmup = timer.phase === 'WARMUP'
|
||||
const isCooldown = timer.phase === 'COOLDOWN'
|
||||
const isInterBlockRest = timer.phase === 'INTER_BLOCK_REST'
|
||||
const isBlockPhase = timer.phase === 'WORK' || timer.phase === 'REST'
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar hidden />
|
||||
<View style={[styles.phaseBg, { backgroundColor: phaseColor }]} />
|
||||
|
||||
<Pressable style={styles.content} onPress={() => setShowControls(s => !s)}>
|
||||
{/* Header */}
|
||||
{showControls && (
|
||||
<View style={[styles.header, { paddingTop: insets.top + SPACING[4] }]}>
|
||||
<Pressable onPress={stopWorkout} style={styles.closeBtn}>
|
||||
<View style={StyleSheet.absoluteFill} />
|
||||
<Icon name="xmark" size={24} tintColor={TEXT.PRIMARY} />
|
||||
</Pressable>
|
||||
<View style={styles.headerCenter}>
|
||||
<Text style={styles.title}>{session.title}</Text>
|
||||
<Text style={styles.subtitle}>Semaine {session.week} · Séance {session.order}</Text>
|
||||
</View>
|
||||
<View style={styles.closeBtn} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Stats overlay */}
|
||||
{showControls && timer.isRunning && !timer.isComplete && !isWarmup && !isCooldown && (
|
||||
<View style={styles.statsContainer}>
|
||||
<StatsOverlay
|
||||
calories={timer.calories}
|
||||
heartRate={null}
|
||||
elapsedRounds={timer.currentRound - 1}
|
||||
totalRounds={timer.totalRounds}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Warmup/Cooldown overlay */}
|
||||
{(isWarmup || isCooldown) && timer.currentWarmupMovement && (
|
||||
<WarmupOverlay
|
||||
movementName={isWarmup ? timer.currentWarmupMovement.name : (timer.currentCooldownMovement?.name ?? '')}
|
||||
movementIndex={isWarmup ? (timer as ReturnType<typeof useTabataTimer>).currentBlockIndex : 0}
|
||||
totalMovements={isWarmup ? session.warmup.movements.length : session.cooldown.movements.length}
|
||||
timeRemaining={timer.timeRemaining}
|
||||
isCooldown={isCooldown}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Inter-block rest */}
|
||||
{isInterBlockRest && (
|
||||
<View style={styles.interBlockContainer}>
|
||||
<Text style={styles.interBlockLabel}>RÉCUPÉRATION</Text>
|
||||
<Text style={styles.interBlockTime}>{formatTime(timer.timeRemaining)}</Text>
|
||||
<BlockIndicator
|
||||
currentBlock={timer.currentBlockIndex}
|
||||
totalBlocks={timer.totalBlocks}
|
||||
/>
|
||||
<Text style={styles.interBlockNext}>
|
||||
Prochain: Bloc {timer.currentBlockIndex + 1}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Main timer ring for WORK/REST phases */}
|
||||
{isBlockPhase && (
|
||||
<>
|
||||
<BlockIndicator
|
||||
currentBlock={timer.currentBlockIndex}
|
||||
totalBlocks={timer.totalBlocks}
|
||||
/>
|
||||
<Animated.View style={[styles.timerContainer, { transform: [{ scale: timerScaleAnim }] }]}>
|
||||
<TimerRing progress={timer.progress} phase={timer.phase === 'WORK' ? 'WORK' : 'REST'} />
|
||||
<View style={styles.timerInner}>
|
||||
<PhaseIndicator phase={timer.phase === 'WORK' ? 'WORK' : 'REST'} />
|
||||
<Text selectable style={styles.timerTime}>{formatTime(timer.timeRemaining)}</Text>
|
||||
<RoundIndicator current={timer.currentRound} total={session.blocks[timer.currentBlockIndex]?.rounds ?? 8} />
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Exercise + tabata tip */}
|
||||
<Text style={styles.exerciseName}>{timer.currentExercise?.name}</Text>
|
||||
<TabataTip tip={timer.currentConseil} visible={timer.phase === 'WORK'} />
|
||||
|
||||
<CoachEncouragement
|
||||
phase={timer.phase === 'WORK' ? 'WORK' : 'REST'}
|
||||
currentRound={timer.currentRound}
|
||||
totalRounds={timer.totalRounds}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Complete state */}
|
||||
{timer.isComplete && (
|
||||
<View style={styles.completeSection}>
|
||||
<Text style={styles.completeTitle}>Séance terminée !</Text>
|
||||
<Text style={[styles.completeSubtitle, { color: GREEN[500] }]}>Excellent travail</Text>
|
||||
<View style={styles.completeStats}>
|
||||
<View style={styles.completeStat}>
|
||||
<Text selectable style={styles.completeStatValue}>{timer.totalBlocks}</Text>
|
||||
<Text style={styles.completeStatLabel}>Blocs</Text>
|
||||
</View>
|
||||
<View style={styles.completeStat}>
|
||||
<Text selectable style={styles.completeStatValue}>{timer.totalRounds}</Text>
|
||||
<Text style={styles.completeStatLabel}>Rounds</Text>
|
||||
</View>
|
||||
<View style={styles.completeStat}>
|
||||
<Text selectable style={styles.completeStatValue}>{timer.calories}</Text>
|
||||
<Text style={styles.completeStatLabel}>Calories</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Now Playing music pill */}
|
||||
{showControls && timer.isRunning && !timer.isComplete && (
|
||||
<View style={[styles.nowPlayingContainer, { bottom: insets.bottom + 100 }]}>
|
||||
<NowPlaying
|
||||
track={music.currentTrack}
|
||||
isReady={music.isReady}
|
||||
onSkipTrack={music.nextTrack}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
{showControls && !timer.isComplete && (
|
||||
<View style={[styles.controls, { paddingBottom: insets.bottom + SPACING[6] }]}>
|
||||
<PlayerControls
|
||||
isRunning={timer.isRunning}
|
||||
isPaused={timer.isPaused}
|
||||
onStart={startTimer}
|
||||
onPause={() => { timer.pause(); haptics.selection() }}
|
||||
onResume={() => { timer.resume(); haptics.selection() }}
|
||||
onStop={stopWorkout}
|
||||
onSkip={handleSkip}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Complete CTA */}
|
||||
{timer.isComplete && (
|
||||
<View style={[styles.controls, { paddingBottom: insets.bottom + SPACING[6] }]}>
|
||||
<Pressable style={styles.doneButton} onPress={completeWorkout}>
|
||||
<Text style={styles.doneButtonText}>Terminé</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: NAVY[900] },
|
||||
phaseBg: { ...StyleSheet.absoluteFillObject, opacity: 0.15 },
|
||||
content: { flex: 1 },
|
||||
|
||||
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: SPACING[4] },
|
||||
closeBtn: { width: 44, height: 44, borderRadius: RADIUS.FULL, alignItems: 'center', justifyContent: 'center', overflow: 'hidden', borderWidth: 1, borderColor: BORDER_COLORS.DIM, backgroundColor: NAVY[800] },
|
||||
headerCenter: { alignItems: 'center' },
|
||||
title: { ...TYPOGRAPHY.HEADLINE, color: TEXT.PRIMARY },
|
||||
subtitle: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY },
|
||||
|
||||
statsContainer: { marginTop: SPACING[4], marginHorizontal: SPACING[4] },
|
||||
|
||||
timerContainer: { alignItems: 'center', justifyContent: 'center', marginTop: SPACING[6] },
|
||||
timerInner: { position: 'absolute', alignItems: 'center' },
|
||||
timerTime: { ...TYPOGRAPHY.TIMER_NUMBER, color: TEXT.PRIMARY, fontVariant: ['tabular-nums'] },
|
||||
|
||||
exerciseName: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY, textAlign: 'center', marginTop: SPACING[4], marginHorizontal: SPACING[4] },
|
||||
|
||||
interBlockContainer: { alignItems: 'center', justifyContent: 'center', flex: 1 },
|
||||
interBlockLabel: { ...TYPOGRAPHY.FOOTNOTE, fontFamily: FONT_FAMILY.SANS_BOLD, letterSpacing: 2, color: AMBER[500], marginBottom: SPACING[2] },
|
||||
interBlockTime: { ...TYPOGRAPHY.TIMER_NUMBER, color: TEXT.PRIMARY, fontVariant: ['tabular-nums'] },
|
||||
interBlockNext: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY, marginTop: SPACING[3] },
|
||||
|
||||
controls: { position: 'absolute', bottom: 0, left: 0, right: 0, alignItems: 'center' },
|
||||
nowPlayingContainer: { position: 'absolute', left: SPACING[6], right: SPACING[6] },
|
||||
|
||||
completeSection: { alignItems: 'center', marginTop: SPACING[8] },
|
||||
completeTitle: { ...TYPOGRAPHY.LARGE_TITLE, color: TEXT.PRIMARY },
|
||||
completeSubtitle: { ...TYPOGRAPHY.TITLE_3, marginTop: SPACING[1] },
|
||||
completeStats: { flexDirection: 'row', marginTop: SPACING[6], gap: SPACING[8] },
|
||||
completeStat: { alignItems: 'center' },
|
||||
completeStatValue: { ...TYPOGRAPHY.TITLE_1, color: TEXT.PRIMARY, fontVariant: ['tabular-nums'] },
|
||||
completeStatLabel: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY, marginTop: SPACING[1] },
|
||||
doneButton: { width: 200, height: 56, borderRadius: RADIUS.MD, borderCurve: 'continuous', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', backgroundColor: GREEN[500] },
|
||||
doneButtonText: { ...TYPOGRAPHY.BUTTON_MEDIUM, color: NAVY[900], letterSpacing: 1 },
|
||||
})
|
||||
67
src/features/player/components/BlockIndicator.tsx
Normal file
67
src/features/player/components/BlockIndicator.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* BlockIndicator — Shows multi-block progress (e.g., "Block 2/3")
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { View, Text, StyleSheet } from 'react-native'
|
||||
import { GREEN, TEXT } from '@/src/shared/constants/colors'
|
||||
import { FONT_FAMILY } from '@/src/shared/constants/typography'
|
||||
import { SPACING, RADIUS } from '@/src/shared/constants'
|
||||
|
||||
interface BlockIndicatorProps {
|
||||
currentBlock: number // 0-based
|
||||
totalBlocks: number
|
||||
accentColor?: string
|
||||
}
|
||||
|
||||
export function BlockIndicator({ currentBlock, totalBlocks, accentColor = GREEN[500] }: BlockIndicatorProps) {
|
||||
if (totalBlocks <= 1) return null
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.label}>
|
||||
Bloc {currentBlock + 1}/{totalBlocks}
|
||||
</Text>
|
||||
<View style={styles.dots}>
|
||||
{Array.from({ length: totalBlocks }).map((_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
styles.dot,
|
||||
{
|
||||
backgroundColor: i < currentBlock
|
||||
? GREEN[500]
|
||||
: i === currentBlock
|
||||
? accentColor
|
||||
: TEXT.TERTIARY,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: SPACING[2],
|
||||
},
|
||||
label: {
|
||||
fontFamily: FONT_FAMILY.SANS_SEMIBOLD,
|
||||
fontSize: 14,
|
||||
color: TEXT.SECONDARY,
|
||||
},
|
||||
dots: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING[1],
|
||||
},
|
||||
dot: {
|
||||
width: SPACING[2],
|
||||
height: SPACING[2],
|
||||
borderRadius: RADIUS.FULL,
|
||||
},
|
||||
})
|
||||
@@ -7,7 +7,8 @@ import { View, Text, StyleSheet } from 'react-native'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { BRAND, darkColors } from '@/src/shared/theme'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { TYPOGRAPHY, FONT_FAMILY_SANS_SEMIBOLD } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
|
||||
interface BurnBarProps {
|
||||
@@ -30,7 +31,7 @@ export function BurnBar({ currentCalories, avgCalories }: BurnBarProps) {
|
||||
{t('units.calUnit', { count: currentCalories })}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.track, { backgroundColor: colors.border.glass }]}>
|
||||
<View style={[styles.track, { backgroundColor: colors.border.dim }]}>
|
||||
<View style={[styles.fill, { width: `${percentage}%` }]} />
|
||||
<View style={[styles.avg, { left: '50%', backgroundColor: colors.text.tertiary }]} />
|
||||
</View>
|
||||
@@ -54,18 +55,18 @@ const styles = StyleSheet.create({
|
||||
value: {
|
||||
...TYPOGRAPHY.CALLOUT,
|
||||
color: BRAND.PRIMARY,
|
||||
fontWeight: '600',
|
||||
fontFamily: FONT_FAMILY_SANS_SEMIBOLD,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
track: {
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
borderRadius: RADIUS.XS,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fill: {
|
||||
height: '100%',
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
borderRadius: 3,
|
||||
borderRadius: RADIUS.XS,
|
||||
},
|
||||
avg: {
|
||||
position: 'absolute',
|
||||
|
||||
23
src/features/player/components/CLAUDE.md
Normal file
23
src/features/player/components/CLAUDE.md
Normal file
@@ -0,0 +1,23 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 9, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5997 | 10:46 AM | 🟣 | Tabata Kine programs system fully implemented with four programs and specialized UI | ~563 |
|
||||
| #5996 | 10:41 AM | 🟣 | Tabata Kine programs system implementation completed | ~460 |
|
||||
| #5973 | 9:36 AM | 🟣 | WarmupOverlay component for exercise phases | ~325 |
|
||||
| #5971 | 9:35 AM | 🟣 | KineTip component created for physiotherapist advice display | ~293 |
|
||||
|
||||
### Apr 10, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6003 | 10:02 AM | 🔵 | WarmupOverlay Component for Exercise Phases | ~264 |
|
||||
| #6002 | " | 🔵 | BlockIndicator Component for Multi-Block Workouts | ~243 |
|
||||
| #6001 | " | 🔵 | KineTip Component Found for Physiotherapist Advice | ~235 |
|
||||
| #5998 | 9:52 AM | 🟣 | Tabata Kine programs system implementation completed | ~711 |
|
||||
</claude-mem-context>
|
||||
@@ -7,6 +7,8 @@ import { View, Pressable, StyleSheet, Animated } from 'react-native'
|
||||
|
||||
import { Icon, type IconName } from '@/src/shared/components/Icon'
|
||||
import { BRAND, darkColors } from '@/src/shared/theme'
|
||||
import { BRAND_DANGER } from '@/src/shared/constants/colors'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPRING } from '@/src/shared/constants/animations'
|
||||
|
||||
interface ControlButtonProps {
|
||||
@@ -45,8 +47,8 @@ export function ControlButton({
|
||||
variant === 'primary'
|
||||
? BRAND.PRIMARY
|
||||
: variant === 'danger'
|
||||
? '#FF3B30'
|
||||
: colors.border.glass
|
||||
? BRAND_DANGER
|
||||
: colors.border.dim
|
||||
|
||||
return (
|
||||
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
|
||||
@@ -77,6 +79,6 @@ const styles = StyleSheet.create({
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 100,
|
||||
borderRadius: RADIUS.FULL,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
/**
|
||||
* NowPlaying — Floating pill showing current music track
|
||||
* Glass background, animated entrance, skip button
|
||||
* Solid navy background, animated entrance, skip button
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect } from 'react'
|
||||
import { View, Text, Pressable, StyleSheet, Animated } from 'react-native'
|
||||
import { BlurView } from 'expo-blur'
|
||||
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
import { BRAND, darkColors } from '@/src/shared/theme'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { TYPOGRAPHY, FONT_FAMILY_SANS_SEMIBOLD } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPRING } from '@/src/shared/constants/animations'
|
||||
import { GREEN, NAVY, BORDER_COLORS, TEXT } from '@/src/shared/constants/colors'
|
||||
import { darkColors } from '@/src/shared/theme'
|
||||
import type { MusicTrack } from '@/src/shared/services/music'
|
||||
|
||||
interface NowPlayingProps {
|
||||
@@ -68,13 +68,8 @@ export function NowPlaying({ track, isReady, onSkipTrack }: NowPlayingProps) {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<BlurView
|
||||
intensity={colors.glass.blurMedium}
|
||||
tint={colors.glass.blurTint}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View style={styles.iconContainer}>
|
||||
<Icon name="music.note" size={16} tintColor={BRAND.PRIMARY} />
|
||||
<Icon name="music.note" size={16} tintColor={GREEN[500]} />
|
||||
</View>
|
||||
<View style={styles.info}>
|
||||
<Text numberOfLines={1} style={[styles.title, { color: colors.text.primary }]}>
|
||||
@@ -103,7 +98,8 @@ const styles = StyleSheet.create({
|
||||
borderCurve: 'continuous',
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: darkColors.border.glass,
|
||||
borderColor: BORDER_COLORS.DIM,
|
||||
backgroundColor: NAVY[800],
|
||||
paddingVertical: SPACING[2],
|
||||
paddingHorizontal: SPACING[3],
|
||||
gap: SPACING[2],
|
||||
@@ -111,8 +107,8 @@ const styles = StyleSheet.create({
|
||||
iconContainer: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: `${BRAND.PRIMARY}20`,
|
||||
borderRadius: RADIUS.LG,
|
||||
backgroundColor: GREEN.DIM,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
@@ -121,7 +117,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
title: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
fontWeight: '600',
|
||||
fontFamily: FONT_FAMILY_SANS_SEMIBOLD,
|
||||
},
|
||||
artist: {
|
||||
...TYPOGRAPHY.CAPTION_2,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { View, Text, StyleSheet } from 'react-native'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { PHASE_COLORS, darkColors } from '@/src/shared/theme'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { TYPOGRAPHY, FONT_FAMILY_SANS_BOLD } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
|
||||
@@ -44,7 +44,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
text: {
|
||||
...TYPOGRAPHY.CALLOUT,
|
||||
fontWeight: '700',
|
||||
fontFamily: FONT_FAMILY_SANS_BOLD,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import { View, Text, StyleSheet } from 'react-native'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { darkColors } from '@/src/shared/theme'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { TYPOGRAPHY, FONT_FAMILY_SANS_BOLD } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
|
||||
interface RoundIndicatorProps {
|
||||
@@ -38,7 +38,7 @@ const styles = StyleSheet.create({
|
||||
...TYPOGRAPHY.BODY,
|
||||
},
|
||||
current: {
|
||||
fontWeight: '700',
|
||||
fontFamily: FONT_FAMILY_SANS_BOLD,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -5,15 +5,14 @@
|
||||
|
||||
import React, { useRef, useEffect } from 'react'
|
||||
import { View, Text, StyleSheet, Animated } from 'react-native'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
import { BRAND, darkColors } from '@/src/shared/theme'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { TYPOGRAPHY, FONT_FAMILY_SANS_BOLD } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPRING } from '@/src/shared/constants/animations'
|
||||
import { GREEN, NAVY, BORDER_COLORS, TEXT, BRAND_DANGER, AMBER } from '@/src/shared/constants/colors'
|
||||
|
||||
interface StatsOverlayProps {
|
||||
calories: number
|
||||
@@ -35,7 +34,6 @@ function StatItem({
|
||||
iconColor: string
|
||||
delay?: number
|
||||
}) {
|
||||
const colors = darkColors
|
||||
const scaleAnim = useRef(new Animated.Value(0)).current
|
||||
|
||||
useEffect(() => {
|
||||
@@ -59,11 +57,11 @@ function StatItem({
|
||||
<Icon name={icon as any} size={16} tintColor={iconColor} />
|
||||
<Text
|
||||
selectable
|
||||
style={[styles.statValue, { color: colors.text.primary }]}
|
||||
style={[styles.statValue, { color: TEXT.PRIMARY }]}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
<Text style={[styles.statLabel, { color: colors.text.tertiary }]}>{label}</Text>
|
||||
<Text style={[styles.statLabel, { color: TEXT.TERTIARY }]}>{label}</Text>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
@@ -75,39 +73,33 @@ export function StatsOverlay({
|
||||
totalRounds,
|
||||
}: StatsOverlayProps) {
|
||||
const { t } = useTranslation()
|
||||
const colors = darkColors
|
||||
const effort = totalRounds > 0
|
||||
? Math.round((elapsedRounds / totalRounds) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BlurView
|
||||
intensity={colors.glass.blurLight}
|
||||
tint={colors.glass.blurTint}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<StatItem
|
||||
value={String(calories)}
|
||||
label={t('screens:player.calories')}
|
||||
icon="flame.fill"
|
||||
iconColor={BRAND.PRIMARY}
|
||||
iconColor={GREEN[500]}
|
||||
delay={0}
|
||||
/>
|
||||
<View style={[styles.divider, { backgroundColor: colors.border.glass }]} />
|
||||
<View style={[styles.divider, { backgroundColor: BORDER_COLORS.DIM }]} />
|
||||
<StatItem
|
||||
value={heartRate ? String(heartRate) : '--'}
|
||||
label="bpm"
|
||||
icon="heart.fill"
|
||||
iconColor="#FF3B30"
|
||||
iconColor={BRAND_DANGER}
|
||||
delay={100}
|
||||
/>
|
||||
<View style={[styles.divider, { backgroundColor: colors.border.glass }]} />
|
||||
<View style={[styles.divider, { backgroundColor: BORDER_COLORS.DIM }]} />
|
||||
<StatItem
|
||||
value={`${effort}%`}
|
||||
label={t('screens:player.effort', { defaultValue: 'effort' })}
|
||||
icon="bolt.fill"
|
||||
iconColor="#FFD60A"
|
||||
iconColor={AMBER[500]}
|
||||
delay={200}
|
||||
/>
|
||||
</View>
|
||||
@@ -123,7 +115,8 @@ const styles = StyleSheet.create({
|
||||
borderCurve: 'continuous',
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: darkColors.border.glass,
|
||||
borderColor: BORDER_COLORS.DIM,
|
||||
backgroundColor: NAVY[800],
|
||||
paddingVertical: SPACING[3],
|
||||
paddingHorizontal: SPACING[2],
|
||||
},
|
||||
@@ -135,7 +128,7 @@ const styles = StyleSheet.create({
|
||||
statValue: {
|
||||
...TYPOGRAPHY.TITLE_2,
|
||||
fontVariant: ['tabular-nums'],
|
||||
fontWeight: '700',
|
||||
fontFamily: FONT_FAMILY_SANS_BOLD,
|
||||
},
|
||||
statLabel: {
|
||||
...TYPOGRAPHY.CAPTION_2,
|
||||
|
||||
50
src/features/player/components/TabataTip.tsx
Normal file
50
src/features/player/components/TabataTip.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* TabataTip — Displays physiotherapist advice during exercise
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { View, Text, StyleSheet } from 'react-native'
|
||||
import { NAVY, ORANGE, TEXT } from '@/src/shared/constants/colors'
|
||||
import { FONT_FAMILY } from '@/src/shared/constants/typography'
|
||||
import { SPACING, RADIUS, LAYOUT } from '@/src/shared/constants'
|
||||
|
||||
interface TabataTipProps {
|
||||
tip: string
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
export function TabataTip({ tip, visible }: TabataTipProps) {
|
||||
if (!visible || !tip) return null
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.icon}>📋</Text>
|
||||
<Text style={styles.tip} numberOfLines={3}>{tip}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
backgroundColor: NAVY[800],
|
||||
borderRadius: RADIUS.MD,
|
||||
padding: SPACING[3],
|
||||
marginHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
gap: SPACING[2],
|
||||
borderWidth: 1,
|
||||
borderColor: ORANGE.DIM,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 16,
|
||||
marginTop: SPACING[0],
|
||||
},
|
||||
tip: {
|
||||
flex: 1,
|
||||
fontFamily: FONT_FAMILY.SANS,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
})
|
||||
@@ -62,7 +62,7 @@ export function TimerRing({
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={colors.border.glass}
|
||||
stroke={colors.border.dim}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
70
src/features/player/components/WarmupOverlay.tsx
Normal file
70
src/features/player/components/WarmupOverlay.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* WarmupOverlay — Displays warmup/cooldown movement with countdown
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { View, Text, StyleSheet } from 'react-native'
|
||||
import { PHASE, GREEN, TEXT } from '@/src/shared/constants/colors'
|
||||
import { FONT_FAMILY } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
|
||||
interface WarmupOverlayProps {
|
||||
movementName: string
|
||||
movementIndex: number
|
||||
totalMovements: number
|
||||
timeRemaining: number
|
||||
isCooldown?: boolean
|
||||
}
|
||||
|
||||
export function WarmupOverlay({
|
||||
movementName,
|
||||
movementIndex,
|
||||
totalMovements,
|
||||
timeRemaining,
|
||||
isCooldown = false,
|
||||
}: WarmupOverlayProps) {
|
||||
const label = isCooldown ? 'RETOUR AU CALME' : 'ÉCHAUFFEMENT'
|
||||
const color = isCooldown ? GREEN[500] : PHASE.PREP
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.phaseLabel, { color }]}>{label}</Text>
|
||||
<Text style={styles.progress}>{movementIndex + 1}/{totalMovements}</Text>
|
||||
<Text style={styles.movement}>{movementName}</Text>
|
||||
<Text style={styles.countdown}>{timeRemaining}s</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: SPACING[10],
|
||||
paddingVertical: SPACING[5],
|
||||
},
|
||||
phaseLabel: {
|
||||
fontFamily: FONT_FAMILY.SANS_BOLD,
|
||||
fontSize: 14,
|
||||
letterSpacing: 2,
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
progress: {
|
||||
fontFamily: FONT_FAMILY.SANS,
|
||||
fontSize: 13,
|
||||
color: TEXT.TERTIARY,
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
movement: {
|
||||
fontFamily: FONT_FAMILY.SANS_SEMIBOLD,
|
||||
fontSize: 22,
|
||||
color: TEXT.PRIMARY,
|
||||
textAlign: 'center',
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
countdown: {
|
||||
fontFamily: FONT_FAMILY.SANS_BOLD,
|
||||
fontSize: 48,
|
||||
color: TEXT.SECONDARY,
|
||||
},
|
||||
})
|
||||
@@ -28,7 +28,7 @@ export interface WatchAvailability {
|
||||
|
||||
export interface WatchMessage {
|
||||
type: string;
|
||||
[key: string]: any;
|
||||
[key: string]: string | number | boolean | undefined;
|
||||
}
|
||||
|
||||
export interface WatchControlMessage extends WatchMessage {
|
||||
@@ -77,6 +77,6 @@ export interface WatchBridgeModule {
|
||||
sendWorkoutState(state: WorkoutState): void;
|
||||
sendMessage(message: WatchMessage): void;
|
||||
sendControl(action: WatchControlAction): void;
|
||||
addListener(eventName: WatchEventName, callback: (data: any) => void): void;
|
||||
removeListener(eventName: WatchEventName, callback: (data: any) => void): void;
|
||||
addListener(eventName: WatchEventName, callback: (data: Record<string, unknown>) => void): void;
|
||||
removeListener(eventName: WatchEventName, callback: (data: Record<string, unknown>) => void): void;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
NativeEventEmitter,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { logger } from '@/src/shared/utils/logger';
|
||||
import type {
|
||||
WorkoutState,
|
||||
WatchControlAction,
|
||||
@@ -80,7 +81,7 @@ export function useWatchSync(options: UseWatchSyncOptions = {}): UseWatchSyncRet
|
||||
if (Platform.OS !== 'ios' || !enabled) return;
|
||||
|
||||
if (!WatchBridge) {
|
||||
console.warn('WatchBridge native module not found');
|
||||
logger.warn('WatchBridge native module not found');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,13 +96,13 @@ export function useWatchSync(options: UseWatchSyncOptions = {}): UseWatchSyncRet
|
||||
useEffect(() => {
|
||||
if (!eventEmitterRef.current || !enabled) return;
|
||||
|
||||
const subscriptions: any[] = [];
|
||||
const subscriptions: { remove(): void }[] = [];
|
||||
|
||||
// Listen for control commands from Watch
|
||||
const controlSubscription = eventEmitterRef.current.addListener(
|
||||
'WatchControlReceived',
|
||||
(data: { action: WatchControlAction }) => {
|
||||
console.log('Received control from Watch:', data.action);
|
||||
logger.log('Received control from Watch:', data.action);
|
||||
|
||||
switch (data.action) {
|
||||
case 'play':
|
||||
@@ -128,7 +129,7 @@ export function useWatchSync(options: UseWatchSyncOptions = {}): UseWatchSyncRet
|
||||
const statusSubscription = eventEmitterRef.current.addListener(
|
||||
'WatchConnectivityStatus',
|
||||
(data: { reachable: boolean }) => {
|
||||
console.log('Watch connectivity changed:', data.reachable);
|
||||
logger.log('Watch connectivity changed:', data.reachable);
|
||||
isReachableRef.current = data.reachable;
|
||||
}
|
||||
);
|
||||
@@ -149,7 +150,7 @@ export function useWatchSync(options: UseWatchSyncOptions = {}): UseWatchSyncRet
|
||||
const stateSubscription = eventEmitterRef.current.addListener(
|
||||
'WatchStateChanged',
|
||||
(data: { isReachable: boolean; isWatchAppInstalled: boolean }) => {
|
||||
console.log('Watch state changed:', data);
|
||||
logger.log('Watch state changed:', data);
|
||||
isReachableRef.current = data.isReachable;
|
||||
isAvailableRef.current = data.isWatchAppInstalled;
|
||||
}
|
||||
@@ -165,7 +166,7 @@ export function useWatchSync(options: UseWatchSyncOptions = {}): UseWatchSyncRet
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== 'ios' || !enabled || !WatchBridge) return;
|
||||
|
||||
checkAvailability().catch(console.error);
|
||||
checkAvailability().catch(logger.error);
|
||||
}, [enabled]);
|
||||
|
||||
const checkAvailability = useCallback(async (): Promise<WatchAvailability> => {
|
||||
@@ -184,7 +185,7 @@ export function useWatchSync(options: UseWatchSyncOptions = {}): UseWatchSyncRet
|
||||
isReachableRef.current = availability.isReachable;
|
||||
return availability;
|
||||
} catch (error) {
|
||||
console.error('Failed to check Watch availability:', error);
|
||||
logger.error('Failed to check Watch availability:', error);
|
||||
return {
|
||||
isSupported: false,
|
||||
isPaired: false,
|
||||
@@ -202,7 +203,7 @@ export function useWatchSync(options: UseWatchSyncOptions = {}): UseWatchSyncRet
|
||||
try {
|
||||
WatchBridge.sendWorkoutState(state);
|
||||
} catch (error) {
|
||||
console.error('Failed to send workout state:', error);
|
||||
logger.error('Failed to send workout state:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -9,11 +9,10 @@
|
||||
|----|------|---|-------|------|
|
||||
| #4889 | 4:46 PM | 🟣 | Created GlassCard component with iOS 18.4 inspired glassmorphism | ~174 |
|
||||
|
||||
### Feb 20, 2026
|
||||
### Apr 11, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5300 | 2:56 PM | 🔵 | GlassCard component architecture examined | ~317 |
|
||||
| #5248 | 1:29 PM | 🔵 | StyledText component provides unified text styling | ~126 |
|
||||
| #5228 | 1:25 PM | 🔄 | Removed v1 features and old scaffolding from TabataFit codebase | ~591 |
|
||||
| #6108 | 7:38 PM | ✅ | WorkoutCard play button comment updated | ~233 |
|
||||
| #6106 | " | 🔵 | WorkoutCard component has hardcoded play button background | ~273 |
|
||||
</claude-mem-context>
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* CollectionCard - Premium collection card with glassmorphism
|
||||
* CollectionCard - Premium collection card
|
||||
* Used in Explore and Browse screens
|
||||
* Supports 'default' and 'hero' variants
|
||||
*/
|
||||
@@ -14,11 +14,10 @@ import {
|
||||
useWindowDimensions,
|
||||
Text as RNText,
|
||||
} from 'react-native'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { BlurView } from 'expo-blur'
|
||||
|
||||
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
|
||||
import { useThemeColors, GRADIENTS } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { TEXT, NAVY, GREEN } from '@/src/shared/constants/colors'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { StyledText } from './StyledText'
|
||||
@@ -69,7 +68,7 @@ export function CollectionCard({ collection, variant = 'default', onPress, image
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
>
|
||||
{/* Background Image or Gradient */}
|
||||
{/* Background Image or Solid Color */}
|
||||
{imageUrl ? (
|
||||
<ImageBackground
|
||||
source={{ uri: imageUrl }}
|
||||
@@ -77,25 +76,27 @@ export function CollectionCard({ collection, variant = 'default', onPress, image
|
||||
imageStyle={{ borderRadius: RADIUS.XL }}
|
||||
resizeMode="cover"
|
||||
>
|
||||
<LinearGradient
|
||||
colors={GRADIENTS.VIDEO_OVERLAY as [string, string]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
<View
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
{
|
||||
backgroundColor: GRADIENTS.VIDEO_OVERLAY[1],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ImageBackground>
|
||||
) : (
|
||||
<LinearGradient
|
||||
colors={collection.gradient ?? [BRAND.PRIMARY, '#FF3B30']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[StyleSheet.absoluteFill, { borderRadius: RADIUS.XL }]}
|
||||
<View
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
{
|
||||
borderRadius: RADIUS.XL,
|
||||
backgroundColor: NAVY[800],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Glassmorphism Overlay */}
|
||||
<View style={styles.overlay}>
|
||||
<BlurView intensity={20} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.content}>
|
||||
<View style={styles.iconContainer}>
|
||||
@@ -103,9 +104,8 @@ export function CollectionCard({ collection, variant = 'default', onPress, image
|
||||
</View>
|
||||
|
||||
<StyledText
|
||||
size={variant === 'hero' ? 22 : 17}
|
||||
weight="bold"
|
||||
color="#FFFFFF"
|
||||
preset={variant === 'hero' ? 'TITLE_2' : 'HEADLINE'}
|
||||
color={TEXT.PRIMARY}
|
||||
numberOfLines={2}
|
||||
style={styles.title}
|
||||
>
|
||||
@@ -114,8 +114,8 @@ export function CollectionCard({ collection, variant = 'default', onPress, image
|
||||
|
||||
{variant === 'hero' && (
|
||||
<StyledText
|
||||
size={14}
|
||||
color="rgba(255,255,255,0.8)"
|
||||
preset="CARD_SUBTITLE"
|
||||
color={TEXT.SECONDARY}
|
||||
numberOfLines={2}
|
||||
style={{ marginBottom: SPACING[1] }}
|
||||
>
|
||||
@@ -124,9 +124,8 @@ export function CollectionCard({ collection, variant = 'default', onPress, image
|
||||
)}
|
||||
|
||||
<StyledText
|
||||
size={13}
|
||||
weight="medium"
|
||||
color="rgba(255,255,255,0.7)"
|
||||
preset="CARD_METADATA"
|
||||
color={TEXT.TERTIARY}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{countLabel}
|
||||
@@ -160,11 +159,6 @@ function createStyles(colors: ThemeColors, screenWidth: number, variant: Collect
|
||||
...containerByVariant[variant],
|
||||
borderRadius: RADIUS.XL,
|
||||
overflow: 'hidden',
|
||||
...colors.shadow.md,
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
@@ -174,13 +168,13 @@ function createStyles(colors: ThemeColors, screenWidth: number, variant: Collect
|
||||
iconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 14,
|
||||
backgroundColor: 'rgba(255,255,255,0.15)',
|
||||
borderRadius: RADIUS.LG,
|
||||
backgroundColor: GREEN.DIM,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING[3],
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.2)',
|
||||
borderColor: GREEN.BORDER,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 24,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useThemeColors } from '@/src/shared/theme'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { TEXT, RED, DARK } from '@/src/shared/constants/colors'
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
|
||||
interface DataDeletionModalProps {
|
||||
@@ -40,7 +41,7 @@ export function DataDeletionModal({
|
||||
animationType="fade"
|
||||
onRequestClose={onCancel}
|
||||
>
|
||||
<View style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.8)' }]}>
|
||||
<View style={[styles.overlay, { backgroundColor: DARK.SCRIM }]}>
|
||||
<View
|
||||
style={[styles.container, { backgroundColor: colors.bg.surface }]}
|
||||
>
|
||||
@@ -48,10 +49,10 @@ export function DataDeletionModal({
|
||||
<View
|
||||
style={[
|
||||
styles.iconContainer,
|
||||
{ backgroundColor: 'rgba(255, 59, 48, 0.1)' },
|
||||
{ backgroundColor: RED.DIM },
|
||||
]}
|
||||
>
|
||||
<Icon name="exclamationmark.triangle.fill" size={40} tintColor="#FF3B30" />
|
||||
<Icon name="exclamationmark.triangle.fill" size={40} tintColor={RED[500]} />
|
||||
</View>
|
||||
|
||||
{/* Title */}
|
||||
@@ -87,7 +88,7 @@ export function DataDeletionModal({
|
||||
onPress={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<StyledText size={17} weight="semibold" color="#FFFFFF">
|
||||
<StyledText preset="HEADLINE" color={TEXT.PRIMARY}>
|
||||
{isDeleting ? 'Deleting...' : t('dataDeletion.deleteButton')}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
@@ -113,14 +114,14 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
borderRadius: RADIUS.LG,
|
||||
borderRadius: RADIUS.XL,
|
||||
padding: SPACING[6],
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
borderRadius: RADIUS.FULL,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING[4],
|
||||
@@ -142,8 +143,8 @@ const styles = StyleSheet.create({
|
||||
deleteButton: {
|
||||
width: '100%',
|
||||
height: LAYOUT.BUTTON_HEIGHT,
|
||||
backgroundColor: '#FF3B30',
|
||||
borderRadius: RADIUS.GLASS_BUTTON,
|
||||
backgroundColor: RED[500],
|
||||
borderRadius: RADIUS.MD,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING[3],
|
||||
|
||||
@@ -1,90 +1,60 @@
|
||||
/**
|
||||
* GlassCard - Liquid Glass Container
|
||||
* iOS 18.4 inspired glassmorphism — theme-aware
|
||||
* Card - Dark Medical surface container
|
||||
* Flat navy cards with border-dim, no glassmorphism
|
||||
*/
|
||||
|
||||
import { ReactNode, useMemo } from 'react'
|
||||
import { StyleSheet, View, ViewStyle } from 'react-native'
|
||||
import { BlurView } from 'expo-blur'
|
||||
|
||||
import { useThemeColors } from '../theme'
|
||||
import type { ThemeColors } from '../theme/types'
|
||||
import { RADIUS } from '../constants/borderRadius'
|
||||
import { ORANGE } from '../constants/colors'
|
||||
|
||||
type GlassVariant = 'base' | 'elevated' | 'inset' | 'tinted'
|
||||
type CardVariant = 'default' | 'accent' | 'tip'
|
||||
|
||||
interface GlassCardProps {
|
||||
interface CardProps {
|
||||
children: ReactNode
|
||||
variant?: GlassVariant
|
||||
variant?: CardVariant
|
||||
style?: ViewStyle
|
||||
hasBlur?: boolean
|
||||
blurIntensity?: number
|
||||
}
|
||||
|
||||
function getVariantStyles(colors: ThemeColors): Record<GlassVariant, ViewStyle> {
|
||||
function getVariantStyles(colors: ThemeColors): Record<CardVariant, ViewStyle> {
|
||||
return {
|
||||
base: {
|
||||
backgroundColor: colors.glass.base.backgroundColor,
|
||||
borderColor: colors.glass.base.borderColor,
|
||||
borderWidth: colors.glass.base.borderWidth,
|
||||
default: {
|
||||
backgroundColor: colors.surface.default.backgroundColor,
|
||||
borderColor: colors.surface.default.borderColor,
|
||||
borderWidth: colors.surface.default.borderWidth,
|
||||
},
|
||||
elevated: {
|
||||
backgroundColor: colors.glass.elevated.backgroundColor,
|
||||
borderColor: colors.glass.elevated.borderColor,
|
||||
borderWidth: colors.glass.elevated.borderWidth,
|
||||
accent: {
|
||||
backgroundColor: colors.surface.accent.backgroundColor,
|
||||
borderColor: colors.surface.accent.borderColor,
|
||||
borderWidth: colors.surface.accent.borderWidth,
|
||||
},
|
||||
inset: {
|
||||
backgroundColor: colors.glass.inset.backgroundColor,
|
||||
borderColor: colors.glass.inset.borderColor,
|
||||
borderWidth: colors.glass.inset.borderWidth,
|
||||
},
|
||||
tinted: {
|
||||
backgroundColor: colors.glass.tinted.backgroundColor,
|
||||
borderColor: colors.glass.tinted.borderColor,
|
||||
borderWidth: colors.glass.tinted.borderWidth,
|
||||
tip: {
|
||||
backgroundColor: colors.surface.tip.backgroundColor,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: ORANGE[500],
|
||||
borderTopColor: colors.surface.default.borderColor,
|
||||
borderRightColor: colors.surface.default.borderColor,
|
||||
borderBottomColor: colors.surface.default.borderColor,
|
||||
borderWidth: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getShadowStyles(colors: ThemeColors): Record<GlassVariant, ViewStyle> {
|
||||
return {
|
||||
base: colors.shadow.sm,
|
||||
elevated: colors.shadow.md,
|
||||
inset: {},
|
||||
tinted: colors.shadow.sm,
|
||||
}
|
||||
}
|
||||
|
||||
export function GlassCard({
|
||||
export function Card({
|
||||
children,
|
||||
variant = 'base',
|
||||
variant = 'default',
|
||||
style,
|
||||
hasBlur = true,
|
||||
blurIntensity,
|
||||
}: GlassCardProps) {
|
||||
}: CardProps) {
|
||||
const colors = useThemeColors()
|
||||
const variantStyles = useMemo(() => getVariantStyles(colors), [colors])
|
||||
const shadowStyles = useMemo(() => getShadowStyles(colors), [colors])
|
||||
|
||||
const glassStyle = variantStyles[variant]
|
||||
const shadowStyle = shadowStyles[variant]
|
||||
const intensity = blurIntensity ?? colors.glass.blurMedium
|
||||
|
||||
if (hasBlur) {
|
||||
return (
|
||||
<View style={[styles.container, glassStyle, shadowStyle, style]}>
|
||||
<BlurView
|
||||
intensity={intensity}
|
||||
tint={colors.glass.blurTint}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View style={styles.content}>{children}</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
const surfaceStyle = variantStyles[variant]
|
||||
|
||||
return (
|
||||
<View style={[styles.container, glassStyle, shadowStyle, style]}>
|
||||
<View style={[styles.container, surfaceStyle, style]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
@@ -92,24 +62,23 @@ export function GlassCard({
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: RADIUS.GLASS_CARD,
|
||||
borderRadius: RADIUS.LG,
|
||||
borderCurve: 'continuous',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
})
|
||||
|
||||
// Preset components for common use cases
|
||||
|
||||
export function GlassCardElevated(props: Omit<GlassCardProps, 'variant'>) {
|
||||
return <GlassCard {...props} variant="elevated" />
|
||||
export function CardAccent(props: Omit<CardProps, 'variant'>) {
|
||||
return <Card {...props} variant="accent" />
|
||||
}
|
||||
|
||||
export function GlassCardInset(props: Omit<GlassCardProps, 'variant'>) {
|
||||
return <GlassCard {...props} variant="inset" hasBlur={false} />
|
||||
export function CardTip(props: Omit<CardProps, 'variant'>) {
|
||||
return <Card {...props} variant="tip" />
|
||||
}
|
||||
|
||||
export function GlassCardTinted(props: Omit<GlassCardProps, 'variant'>) {
|
||||
return <GlassCard {...props} variant="tinted" />
|
||||
}
|
||||
// Backward-compatible aliases
|
||||
export const GlassCard = Card
|
||||
export const GlassCardElevated = CardAccent
|
||||
export const GlassCardInset = Card
|
||||
export const GlassCardTinted = CardAccent
|
||||
|
||||
79
src/shared/components/Mascot.tsx
Normal file
79
src/shared/components/Mascot.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react'
|
||||
import { StyleSheet, View, ViewStyle } from 'react-native'
|
||||
import { Image } from 'expo-image'
|
||||
import { StyledText } from './StyledText'
|
||||
import { Card } from './GlassCard'
|
||||
import { NAVY } from '../constants/colors'
|
||||
import { SPACING } from '../constants/spacing'
|
||||
|
||||
export interface MascotProps {
|
||||
message?: string
|
||||
style?: ViewStyle
|
||||
animate?: boolean
|
||||
size?: number
|
||||
}
|
||||
|
||||
const mascotImage = require('@/assets/mascot.gif')
|
||||
|
||||
export function Mascot({ message, style, animate = true, size }: MascotProps) {
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
{message && (
|
||||
<View style={styles.bubbleContainer}>
|
||||
<Card style={styles.bubble}>
|
||||
<StyledText size={13} weight="medium" style={{ textAlign: 'center' }}>
|
||||
{message}
|
||||
</StyledText>
|
||||
</Card>
|
||||
<View style={styles.caret} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Image
|
||||
source={mascotImage}
|
||||
style={[styles.image, size ? { width: size, height: size } : undefined]}
|
||||
contentFit="contain"
|
||||
cachePolicy="memory-disk"
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
maxWidth: 140,
|
||||
},
|
||||
image: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
},
|
||||
bubbleContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING[2],
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
width: '100%',
|
||||
},
|
||||
bubble: {
|
||||
paddingHorizontal: SPACING[3],
|
||||
paddingVertical: 10,
|
||||
width: '100%',
|
||||
},
|
||||
caret: {
|
||||
position: 'absolute',
|
||||
bottom: -8,
|
||||
alignSelf: 'center',
|
||||
width: 0,
|
||||
height: 0,
|
||||
backgroundColor: 'transparent',
|
||||
borderStyle: 'solid',
|
||||
borderLeftWidth: 8,
|
||||
borderRightWidth: 8,
|
||||
borderTopWidth: 8,
|
||||
borderLeftColor: 'transparent',
|
||||
borderRightColor: 'transparent',
|
||||
borderTopColor: NAVY[800],
|
||||
},
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import { Icon } from './Icon'
|
||||
import { useThemeColors, BRAND } from '../theme'
|
||||
import type { ThemeColors } from '../theme/types'
|
||||
import { SPACING, LAYOUT } from '../constants/spacing'
|
||||
import { RADIUS } from '../constants/borderRadius'
|
||||
import { DURATION, EASE } from '../constants/animations'
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window')
|
||||
@@ -110,13 +111,13 @@ function createStyles(colors: ThemeColors) {
|
||||
height: 3,
|
||||
backgroundColor: colors.bg.surface,
|
||||
marginHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
borderRadius: 2,
|
||||
borderRadius: RADIUS.XS,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
borderRadius: 2,
|
||||
borderRadius: RADIUS.XS,
|
||||
},
|
||||
backButton: {
|
||||
marginTop: SPACING[3],
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
/**
|
||||
* TabataFit StyledText
|
||||
* Unified text component — uses theme for default color
|
||||
* Supports typography presets via TYPOGRAPHY tokens
|
||||
*/
|
||||
|
||||
import { Text as RNText, TextStyle, StyleProp } from 'react-native'
|
||||
import { useThemeColors } from '../theme'
|
||||
import { TYPOGRAPHY } from '../constants/typography'
|
||||
|
||||
type FontWeight = 'regular' | 'medium' | 'semibold' | 'bold'
|
||||
|
||||
type TypographyPreset = keyof typeof TYPOGRAPHY
|
||||
|
||||
const WEIGHT_MAP: Record<FontWeight, TextStyle['fontWeight']> = {
|
||||
regular: '400',
|
||||
medium: '500',
|
||||
@@ -17,7 +21,11 @@ const WEIGHT_MAP: Record<FontWeight, TextStyle['fontWeight']> = {
|
||||
|
||||
interface StyledTextProps {
|
||||
children: React.ReactNode
|
||||
/** Typography preset — when provided, overrides size/weight with full TYPOGRAPHY token */
|
||||
preset?: TypographyPreset
|
||||
/** Font size in px (ignored when preset is set) */
|
||||
size?: number
|
||||
/** Font weight (ignored when preset is set) */
|
||||
weight?: FontWeight
|
||||
color?: string
|
||||
style?: StyleProp<TextStyle>
|
||||
@@ -26,6 +34,7 @@ interface StyledTextProps {
|
||||
|
||||
export function StyledText({
|
||||
children,
|
||||
preset,
|
||||
size = 17,
|
||||
weight = 'regular',
|
||||
color,
|
||||
@@ -35,16 +44,13 @@ export function StyledText({
|
||||
const colors = useThemeColors()
|
||||
const resolvedColor = color ?? colors.text.primary
|
||||
|
||||
const baseStyle: TextStyle = preset
|
||||
? { ...TYPOGRAPHY[preset], color: resolvedColor }
|
||||
: { fontSize: size, fontWeight: WEIGHT_MAP[weight], color: resolvedColor }
|
||||
|
||||
return (
|
||||
<RNText
|
||||
style={[
|
||||
{
|
||||
fontSize: size,
|
||||
fontWeight: WEIGHT_MAP[weight],
|
||||
color: resolvedColor,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
style={[baseStyle, style]}
|
||||
numberOfLines={numberOfLines}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useThemeColors } from '@/src/shared/theme'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { BRAND } from '@/src/shared/constants/colors'
|
||||
import { BRAND, TEXT, GREEN } from '@/src/shared/constants/colors'
|
||||
import { Icon, type IconName } from '@/src/shared/components/Icon'
|
||||
|
||||
interface SyncConsentModalProps {
|
||||
@@ -42,7 +42,7 @@ export function SyncConsentModal({
|
||||
animationType="fade"
|
||||
onRequestClose={onDecline}
|
||||
>
|
||||
<View style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.8)' }]}>
|
||||
<View style={[styles.overlay, { backgroundColor: colors.bg.scrim }]}>
|
||||
<View
|
||||
style={[styles.container, { backgroundColor: colors.bg.surface }]}
|
||||
>
|
||||
@@ -100,7 +100,7 @@ export function SyncConsentModal({
|
||||
onPress={handleAccept}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<StyledText size={17} weight="semibold" color="#FFFFFF">
|
||||
<StyledText size={17} weight="semibold" color={TEXT.PRIMARY}>
|
||||
{isLoading ? 'Setting up...' : t('sync.primaryButton')}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
@@ -123,7 +123,7 @@ function BenefitRow({
|
||||
}: {
|
||||
icon: IconName
|
||||
text: string
|
||||
colors: any
|
||||
colors: ReturnType<typeof useThemeColors>
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.benefitRow}>
|
||||
@@ -153,15 +153,15 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
borderRadius: RADIUS.LG,
|
||||
borderRadius: RADIUS.XL,
|
||||
padding: SPACING[6],
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.1)',
|
||||
borderRadius: RADIUS.FULL,
|
||||
backgroundColor: GREEN.DIM,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING[4],
|
||||
@@ -193,7 +193,7 @@ const styles = StyleSheet.create({
|
||||
width: '100%',
|
||||
height: LAYOUT.BUTTON_HEIGHT,
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
borderRadius: RADIUS.GLASS_BUTTON,
|
||||
borderRadius: RADIUS.MD,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING[3],
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useRef, useEffect, useCallback } from 'react'
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import { useVideoPlayer, VideoView } from 'expo-video'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { BRAND } from '../constants/colors'
|
||||
import { BRAND, NAVY } from '../constants/colors'
|
||||
|
||||
interface VideoPlayerProps {
|
||||
/** HLS or MP4 video URL */
|
||||
@@ -26,7 +26,7 @@ interface VideoPlayerProps {
|
||||
|
||||
export function VideoPlayer({
|
||||
videoUrl,
|
||||
gradientColors = [BRAND.PRIMARY, BRAND.PRIMARY_DARK],
|
||||
gradientColors = [BRAND.PRIMARY, BRAND.SECONDARY],
|
||||
mode = 'preview',
|
||||
isPlaying = true,
|
||||
style,
|
||||
@@ -76,6 +76,6 @@ export function VideoPlayer({
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#000',
|
||||
backgroundColor: NAVY[900],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* WorkoutCard - Premium workout card with glassmorphism
|
||||
* Used in Home and Browse screens
|
||||
* WorkoutCard - Dark Medical workout card
|
||||
* Flat navy surface with border-dim, no glassmorphism
|
||||
*/
|
||||
|
||||
import { useMemo, useRef, useCallback } from 'react'
|
||||
@@ -15,10 +15,11 @@ import {
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
|
||||
import { useThemeColors } from '@/src/shared/theme'
|
||||
import { BRAND, GRADIENTS, TEXT, NAVY, BORDER_COLORS, PHASE } from '@/src/shared/constants/colors'
|
||||
import { FONT_FAMILY_SANS_SEMIBOLD } from '@/src/shared/constants/typography'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
@@ -41,10 +42,10 @@ interface WorkoutCardProps {
|
||||
|
||||
export const CATEGORY_COLORS: Record<WorkoutCategory, string> = {
|
||||
'full-body': BRAND.PRIMARY,
|
||||
'core': '#5AC8FA',
|
||||
'upper-body': '#BF5AF2',
|
||||
'lower-body': '#30D158',
|
||||
'cardio': '#FF9500',
|
||||
'core': BRAND.INFO,
|
||||
'upper-body': TEXT.SECONDARY,
|
||||
'lower-body': BRAND.PRIMARY,
|
||||
'cardio': PHASE.PREP,
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<WorkoutCategory, string> = {
|
||||
@@ -94,7 +95,7 @@ export function WorkoutCard({
|
||||
const scaleValue = useRef(new Animated.Value(1)).current
|
||||
const handlePressIn = useCallback(() => {
|
||||
Animated.spring(scaleValue, {
|
||||
toValue: 0.96,
|
||||
toValue: 0.97,
|
||||
useNativeDriver: true,
|
||||
speed: 50,
|
||||
bounciness: 4,
|
||||
@@ -129,47 +130,43 @@ export function WorkoutCard({
|
||||
<ImageBackground
|
||||
source={workout.thumbnailUrl ? { uri: workout.thumbnailUrl } : undefined}
|
||||
style={StyleSheet.absoluteFill}
|
||||
imageStyle={{ borderRadius: RADIUS.XL }}
|
||||
imageStyle={{ borderRadius: RADIUS.LG }}
|
||||
resizeMode="cover"
|
||||
>
|
||||
{/* Gradient Overlay */}
|
||||
{/* Dark overlay for text readability */}
|
||||
<LinearGradient
|
||||
colors={GRADIENTS.VIDEO_OVERLAY as [string, string]}
|
||||
colors={GRADIENTS.CARD_OVERLAY}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
</ImageBackground>
|
||||
|
||||
{/* Category Badge */}
|
||||
<View style={[styles.categoryBadge, { backgroundColor: `${categoryColor}25`, borderColor: `${categoryColor}40` }]}>
|
||||
<BlurView intensity={20} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
{/* Category Badge — flat navy */}
|
||||
<View style={[styles.categoryBadge, { backgroundColor: `${categoryColor}20`, borderColor: `${categoryColor}40` }]}>
|
||||
<RNText style={[styles.categoryText, { color: categoryColor }]}>
|
||||
{CATEGORY_LABELS[workout.category]}
|
||||
</RNText>
|
||||
</View>
|
||||
|
||||
{/* Play Button */}
|
||||
{/* Play Button — flat navy circle */}
|
||||
<View style={styles.playButtonContainer}>
|
||||
<View style={styles.playButton}>
|
||||
<BlurView intensity={colors.glass.blurMedium} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
<Icon name={isLocked ? 'lock.fill' : 'play.fill'} size={24} tintColor="#FFFFFF" style={isLocked ? undefined : { marginLeft: 2 }} />
|
||||
<Icon name={isLocked ? 'lock.fill' : 'play.fill'} size={24} tintColor={TEXT.PRIMARY} style={isLocked ? undefined : { marginLeft: 2 }} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Content at Bottom */}
|
||||
<View style={styles.content}>
|
||||
<StyledText
|
||||
size={variant === 'featured' ? 22 : 17}
|
||||
weight="bold"
|
||||
color="#FFFFFF"
|
||||
preset={variant === 'featured' ? 'TITLE_2' : 'HEADLINE'}
|
||||
color={TEXT.PRIMARY}
|
||||
numberOfLines={2}
|
||||
style={styles.title}
|
||||
>
|
||||
{displayTitle}
|
||||
</StyledText>
|
||||
<StyledText
|
||||
size={13}
|
||||
weight="medium"
|
||||
color="rgba(255,255,255,0.7)"
|
||||
preset="CARD_METADATA"
|
||||
color={TEXT.SECONDARY}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{displayMetadata}
|
||||
@@ -179,12 +176,15 @@ export function WorkoutCard({
|
||||
)
|
||||
}
|
||||
|
||||
function createStyles(colors: ThemeColors) {
|
||||
function createStyles(_colors: ThemeColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: RADIUS.XL,
|
||||
borderRadius: RADIUS.LG,
|
||||
borderCurve: 'continuous',
|
||||
overflow: 'hidden',
|
||||
...colors.shadow.lg,
|
||||
backgroundColor: NAVY[800],
|
||||
borderWidth: 1,
|
||||
borderColor: BORDER_COLORS.DIM,
|
||||
},
|
||||
categoryBadge: {
|
||||
position: 'absolute',
|
||||
@@ -195,12 +195,13 @@ function createStyles(colors: ThemeColors) {
|
||||
paddingHorizontal: SPACING[3],
|
||||
paddingVertical: SPACING[1],
|
||||
borderRadius: RADIUS.SM,
|
||||
borderCurve: 'continuous',
|
||||
borderWidth: 1,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
categoryText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
fontFamily: FONT_FAMILY_SANS_SEMIBOLD,
|
||||
},
|
||||
playButtonContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
@@ -210,13 +211,12 @@ function createStyles(colors: ThemeColors) {
|
||||
playButton: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
borderRadius: RADIUS.FULL,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'rgba(17,34,64,0.7)', // Semi-transparent NAVY[800] overlay
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.2)',
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
borderColor: BORDER_COLORS.DIM,
|
||||
},
|
||||
content: {
|
||||
position: 'absolute',
|
||||
|
||||
11
src/shared/components/loading/CLAUDE.md
Normal file
11
src/shared/components/loading/CLAUDE.md
Normal file
@@ -0,0 +1,11 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 11, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6105 | 7:38 PM | 🔵 | Skeleton component contains hardcoded shimmer color | ~246 |
|
||||
</claude-mem-context>
|
||||
@@ -3,17 +3,18 @@
|
||||
* Shimmer loading states for better UX
|
||||
*/
|
||||
|
||||
import { View, StyleSheet, Animated } from 'react-native'
|
||||
import { View, StyleSheet, Animated, type ViewStyle, type DimensionValue } from 'react-native'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useThemeColors } from '@/src/shared/theme'
|
||||
import { DARK } from '@/src/shared/constants/colors'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
|
||||
interface SkeletonProps {
|
||||
width?: number | string
|
||||
width?: DimensionValue
|
||||
height?: number
|
||||
borderRadius?: number
|
||||
style?: any
|
||||
style?: ViewStyle
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,7 +145,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
shimmer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
backgroundColor: DARK.OVERLAY_2,
|
||||
width: 100,
|
||||
},
|
||||
card: {
|
||||
|
||||
11
src/shared/components/native/CLAUDE.md
Normal file
11
src/shared/components/native/CLAUDE.md
Normal file
@@ -0,0 +1,11 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 11, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6116 | 7:39 PM | 🔴 | NativeSection missing NAVY color import added | ~239 |
|
||||
</claude-mem-context>
|
||||
94
src/shared/components/native/NativeButton.tsx
Normal file
94
src/shared/components/native/NativeButton.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Pressable, StyleSheet, Text, type ViewStyle } from 'react-native'
|
||||
import type { SFSymbol } from 'sf-symbols-typescript'
|
||||
import { GREEN, NAVY, RED, TEXT } from '@/src/shared/constants/colors'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { LAYOUT } from '@/src/shared/constants/spacing'
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive' | 'icon'
|
||||
|
||||
export interface NativeButtonProps {
|
||||
variant?: ButtonVariant
|
||||
title?: string
|
||||
systemImage?: SFSymbol
|
||||
onPress?: () => void
|
||||
disabled?: boolean
|
||||
color?: string
|
||||
controlSize?: 'mini' | 'small' | 'regular' | 'large'
|
||||
fullWidth?: boolean
|
||||
style?: ViewStyle
|
||||
testID?: string
|
||||
}
|
||||
|
||||
export function NativeButton(props: NativeButtonProps) {
|
||||
const { variant = 'primary', title, onPress, disabled, fullWidth, style, testID } = props
|
||||
|
||||
const buttonStyle = getVariantStyle(variant)
|
||||
const textStyle = getVariantTextStyle(variant)
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
style={({ pressed }) => [
|
||||
styles.base,
|
||||
fullWidth && styles.fullWidth,
|
||||
buttonStyle,
|
||||
pressed && styles.pressed,
|
||||
disabled && styles.disabled,
|
||||
style,
|
||||
]}
|
||||
testID={testID}
|
||||
>
|
||||
{title ? <Text style={[styles.text, textStyle]}>{title}</Text> : null}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
function getVariantStyle(variant: ButtonVariant): ViewStyle {
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return { backgroundColor: GREEN[500] }
|
||||
case 'secondary':
|
||||
return { backgroundColor: 'transparent', borderWidth: 1.5, borderColor: GREEN[500] }
|
||||
case 'ghost':
|
||||
case 'destructive':
|
||||
case 'icon':
|
||||
return { backgroundColor: 'transparent' }
|
||||
}
|
||||
}
|
||||
|
||||
function getVariantTextStyle(variant: ButtonVariant) {
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return { color: NAVY[900] }
|
||||
case 'destructive':
|
||||
return { color: RED[500] }
|
||||
default:
|
||||
return { color: TEXT.PRIMARY }
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
height: LAYOUT.BUTTON_HEIGHT,
|
||||
borderRadius: RADIUS.MD,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: LAYOUT.TOUCH_TARGET,
|
||||
},
|
||||
fullWidth: {
|
||||
width: '100%',
|
||||
},
|
||||
text: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.8,
|
||||
transform: [{ scale: 0.98 }],
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
})
|
||||
47
src/shared/components/native/NativeGauge.tsx
Normal file
47
src/shared/components/native/NativeGauge.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
import { GREEN, TEXT } from '@/src/shared/constants/colors'
|
||||
|
||||
export interface NativeGaugeProps {
|
||||
value: number
|
||||
maxValue?: number
|
||||
label?: string
|
||||
color?: string
|
||||
size?: number
|
||||
testID?: string
|
||||
}
|
||||
|
||||
export function NativeGauge(props: NativeGaugeProps) {
|
||||
const { value, maxValue = 1, label, color, size = 52 } = props
|
||||
const percentage = Math.round((value / maxValue) * 100)
|
||||
const gaugeColor = color ?? GREEN[500]
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { width: size, height: size }]}>
|
||||
<View style={[styles.ring, { borderColor: gaugeColor }]}>
|
||||
<View style={[styles.valueText, { width: size * 0.7, height: size * 0.7, borderRadius: (size * 0.7) / 2, borderWidth: 3, borderColor: gaugeColor, alignItems: 'center', justifyContent: 'center' }]}>
|
||||
<View />
|
||||
</View>
|
||||
</View>
|
||||
{label ? <View style={styles.labelContainer} /> : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
ring: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 9999,
|
||||
borderWidth: 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
valueText: {},
|
||||
labelContainer: {
|
||||
marginTop: 4,
|
||||
},
|
||||
})
|
||||
64
src/shared/components/native/NativeLabeledRow.tsx
Normal file
64
src/shared/components/native/NativeLabeledRow.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { StyleSheet, Text, View, Pressable } from 'react-native'
|
||||
import { TEXT } from '@/src/shared/constants/colors'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
|
||||
export interface NativeLabeledRowProps {
|
||||
label: string
|
||||
value?: string
|
||||
icon?: string
|
||||
chevron?: boolean
|
||||
onPress?: () => void
|
||||
children?: React.ReactNode
|
||||
testID?: string
|
||||
}
|
||||
|
||||
export function NativeLabeledRow(props: NativeLabeledRowProps) {
|
||||
const { label, value, chevron, onPress, children } = props
|
||||
|
||||
const content = (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<View style={styles.right}>
|
||||
{value ? <Text style={styles.value}>{value}</Text> : null}
|
||||
{children}
|
||||
{chevron ? <Text style={styles.chevron}>›</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
||||
if (onPress) {
|
||||
return <Pressable onPress={onPress}>{content}</Pressable>
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: 44,
|
||||
paddingHorizontal: SPACING[4],
|
||||
paddingVertical: SPACING[3],
|
||||
},
|
||||
label: {
|
||||
fontSize: 17,
|
||||
color: TEXT.PRIMARY,
|
||||
flex: 1,
|
||||
},
|
||||
right: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[2],
|
||||
},
|
||||
value: {
|
||||
fontSize: 17,
|
||||
color: TEXT.TERTIARY,
|
||||
},
|
||||
chevron: {
|
||||
fontSize: 20,
|
||||
color: TEXT.TERTIARY,
|
||||
marginLeft: SPACING[1],
|
||||
},
|
||||
})
|
||||
36
src/shared/components/native/NativeList.tsx
Normal file
36
src/shared/components/native/NativeList.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { type ViewStyle, View, StyleSheet } from 'react-native'
|
||||
import { NAVY } from '@/src/shared/constants/colors'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
|
||||
export interface NativeListProps {
|
||||
children: React.ReactNode
|
||||
style?: ViewStyle
|
||||
scrollEnabled?: boolean
|
||||
height?: number
|
||||
testID?: string
|
||||
}
|
||||
|
||||
export function NativeList(props: NativeListProps) {
|
||||
const { children, style, height } = props
|
||||
return (
|
||||
<View style={[styles.list, height ? { height } : undefined, style]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function calculateListHeight(rows: number, sections: number = 1): number {
|
||||
return rows * LAYOUT.LIST_ROW_HEIGHT + sections * LAYOUT.LIST_HEADER_HEIGHT + 20
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
list: {
|
||||
marginHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
marginTop: LAYOUT.SECTION_GAP,
|
||||
marginBottom: SPACING[3],
|
||||
backgroundColor: NAVY[800],
|
||||
borderRadius: RADIUS.XL,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
})
|
||||
44
src/shared/components/native/NativeSection.tsx
Normal file
44
src/shared/components/native/NativeSection.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import { NAVY, TEXT } from '@/src/shared/constants/colors'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { FONT_FAMILY } from '@/src/shared/constants/typography'
|
||||
|
||||
export interface NativeSectionProps {
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function NativeSection(props: NativeSectionProps) {
|
||||
const { title, children } = props
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{title ? (
|
||||
<Text style={styles.header}>{title.toUpperCase()}</Text>
|
||||
) : null}
|
||||
<View style={styles.content}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: SPACING[5],
|
||||
},
|
||||
header: {
|
||||
fontSize: 13,
|
||||
fontFamily: FONT_FAMILY.SANS_SEMIBOLD,
|
||||
color: TEXT.TERTIARY,
|
||||
letterSpacing: 0.06,
|
||||
textTransform: 'uppercase',
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
content: {
|
||||
backgroundColor: NAVY[800],
|
||||
borderRadius: RADIUS.XL,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
})
|
||||
25
src/shared/components/native/NativeSwitch.tsx
Normal file
25
src/shared/components/native/NativeSwitch.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Switch as RNSwitch, type ViewStyle } from 'react-native'
|
||||
import { GREEN, TEXT } from '@/src/shared/constants/colors'
|
||||
|
||||
export interface NativeSwitchProps {
|
||||
value: boolean
|
||||
onValueChange?: (value: boolean) => void
|
||||
color?: string
|
||||
disabled?: boolean
|
||||
style?: ViewStyle
|
||||
testID?: string
|
||||
}
|
||||
|
||||
export function NativeSwitch(props: NativeSwitchProps) {
|
||||
const { value, onValueChange, color, disabled, style } = props
|
||||
return (
|
||||
<RNSwitch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
trackColor={{ false: TEXT.DISABLED, true: color ?? GREEN[500] }}
|
||||
thumbColor={TEXT.PRIMARY}
|
||||
disabled={disabled}
|
||||
style={style}
|
||||
/>
|
||||
)
|
||||
}
|
||||
17
src/shared/components/native/index.ts
Normal file
17
src/shared/components/native/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export { NativeButton } from './NativeButton'
|
||||
export type { NativeButtonProps } from './NativeButton'
|
||||
|
||||
export { NativeList, calculateListHeight } from './NativeList'
|
||||
export type { NativeListProps } from './NativeList'
|
||||
|
||||
export { NativeSection } from './NativeSection'
|
||||
export type { NativeSectionProps } from './NativeSection'
|
||||
|
||||
export { NativeSwitch } from './NativeSwitch'
|
||||
export type { NativeSwitchProps } from './NativeSwitch'
|
||||
|
||||
export { NativeLabeledRow } from './NativeLabeledRow'
|
||||
export type { NativeLabeledRowProps } from './NativeLabeledRow'
|
||||
|
||||
export { NativeGauge } from './NativeGauge'
|
||||
export type { NativeGaugeProps } from './NativeGauge'
|
||||
@@ -10,15 +10,6 @@
|
||||
| #4831 | 2:57 PM | 🔄 | Spacing Constants Enhanced with Semantic Aliases and Documentation | ~358 |
|
||||
| #4830 | 2:56 PM | 🔵 | Design System Constants Index Review | ~293 |
|
||||
| #4829 | " | 🔵 | Existing Animation Constants Reviewed | ~331 |
|
||||
| #4828 | " | 🔵 | Color System Constants Review for BDSD Planning | ~412 |
|
||||
| #4827 | " | 🔵 | Spacing Constants Implementation Review | ~254 |
|
||||
| #4825 | " | 🔵 | Typography Constants Implementation Verification | ~264 |
|
||||
| #4779 | 1:21 PM | 🔵 | Typography System Constants Analysis | ~354 |
|
||||
| #4778 | 1:20 PM | 🔵 | Design System Color Constants Analysis | ~392 |
|
||||
|
||||
### Feb 20, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5242 | 1:29 PM | 🔵 | Design system constants barrel export reviewed | ~370 |
|
||||
</claude-mem-context>
|
||||
@@ -1,23 +1,19 @@
|
||||
/**
|
||||
* TabataFit Border Radius System
|
||||
* Liquid Glass uses generous rounding
|
||||
* Dark Medical — clean, minimal rounding
|
||||
*/
|
||||
|
||||
export const RADIUS = {
|
||||
NONE: 0,
|
||||
XS: 4,
|
||||
SM: 8,
|
||||
MD: 12,
|
||||
LG: 16,
|
||||
XL: 20,
|
||||
XXL: 24,
|
||||
XXXL: 32,
|
||||
XS: 2, // Progress bar, hairline element
|
||||
SM: 6, // Badge, chip, tag
|
||||
MD: 10, // Button, input, tip card
|
||||
LG: 14, // Standard program card
|
||||
XL: 18, // Large card, modal
|
||||
'2XL': 22, // Icon container, medium element
|
||||
'3XL': 28, // Large element
|
||||
|
||||
// Special
|
||||
FULL: 9999,
|
||||
|
||||
// Liquid glass specific
|
||||
GLASS_CARD: 20,
|
||||
GLASS_MODAL: 28,
|
||||
GLASS_BUTTON: 14,
|
||||
PILL: 9999, // Pill, toggle, progress bar
|
||||
FULL: 9999, // Circle (icon button, avatar, streak dot)
|
||||
} as const
|
||||
|
||||
@@ -46,7 +46,7 @@ export const BORDER_COLORS = {
|
||||
} as const
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ORANGE (Kiné Tips ONLY)
|
||||
// ORANGE (Tabata Tips ONLY)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const ORANGE = {
|
||||
|
||||
@@ -32,11 +32,15 @@ export const SPACE = {
|
||||
} as const
|
||||
|
||||
export const LAYOUT = {
|
||||
SCREEN_PADDING: 24,
|
||||
SCREEN_PADDING: 20,
|
||||
CARD_PADDING: 16,
|
||||
BUTTON_HEIGHT: 56,
|
||||
BUTTON_HEIGHT_SM: 44,
|
||||
TAB_BAR_HEIGHT: 83,
|
||||
HEADER_HEIGHT: 44,
|
||||
TOUCH_TARGET: 44,
|
||||
SECTION_GAP: 24,
|
||||
LIST_ROW_HEIGHT: 44,
|
||||
LIST_HEADER_HEIGHT: 35,
|
||||
GROUPED_INSET_HORIZONTAL: 20,
|
||||
} as const
|
||||
|
||||
@@ -3,12 +3,9 @@
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Feb 20, 2026
|
||||
### Apr 11, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5319 | 2:59 PM | 🔵 | Workout data structure details examined | ~355 |
|
||||
| #5315 | " | 🔵 | Data layer architecture examined | ~366 |
|
||||
| #5314 | " | 🔵 | Workout data structure examined | ~321 |
|
||||
| #5298 | 2:56 PM | 🔵 | Achievement system data structure examined | ~333 |
|
||||
| #6138 | 7:44 PM | 🔵 | Program accent colors contain hardcoded hex values | ~244 |
|
||||
</claude-mem-context>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { supabase, isSupabaseConfigured } from '../supabase'
|
||||
import { logger } from '../utils/logger'
|
||||
import { WORKOUTS, TRAINERS, PROGRAMS, ACHIEVEMENTS } from './index'
|
||||
import type { Workout, Trainer, Collection, Program, ProgramId } from '../types'
|
||||
import type { Database } from '../supabase/database.types'
|
||||
@@ -7,8 +8,6 @@ type WorkoutRow = Database['public']['Tables']['workouts']['Row']
|
||||
type TrainerRow = Database['public']['Tables']['trainers']['Row']
|
||||
type CollectionRow = Database['public']['Tables']['collections']['Row']
|
||||
type CollectionWorkoutRow = Database['public']['Tables']['collection_workouts']['Row']
|
||||
type ProgramRow = Database['public']['Tables']['programs']['Row']
|
||||
type ProgramWorkoutRow = Database['public']['Tables']['program_workouts']['Row']
|
||||
|
||||
function mapWorkoutFromDB(row: WorkoutRow): Workout {
|
||||
return {
|
||||
@@ -57,27 +56,6 @@ function mapCollectionFromDB(
|
||||
}
|
||||
}
|
||||
|
||||
function mapProgramFromDB(
|
||||
row: ProgramRow,
|
||||
_workoutIds: string[]
|
||||
): Program {
|
||||
const localProgram = PROGRAMS[row.id as ProgramId]
|
||||
if (localProgram) {
|
||||
return localProgram
|
||||
}
|
||||
return {
|
||||
id: row.id as Program['id'],
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
durationWeeks: 4,
|
||||
workoutsPerWeek: 5,
|
||||
totalWorkouts: 20,
|
||||
equipment: { required: [], optional: [] },
|
||||
focusAreas: [],
|
||||
weeks: [],
|
||||
}
|
||||
}
|
||||
|
||||
class SupabaseDataService {
|
||||
async getAllWorkouts(): Promise<Workout[]> {
|
||||
if (!isSupabaseConfigured()) {
|
||||
@@ -90,7 +68,7 @@ class SupabaseDataService {
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching workouts:', error)
|
||||
logger.error('Error fetching workouts:', error)
|
||||
return WORKOUTS
|
||||
}
|
||||
|
||||
@@ -109,7 +87,7 @@ class SupabaseDataService {
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
console.error('Error fetching workout:', error)
|
||||
logger.error('Error fetching workout:', error)
|
||||
return WORKOUTS.find((w: Workout) => w.id === id)
|
||||
}
|
||||
|
||||
@@ -127,7 +105,7 @@ class SupabaseDataService {
|
||||
.eq('category', category)
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching workouts by category:', error)
|
||||
logger.error('Error fetching workouts by category:', error)
|
||||
return WORKOUTS.filter((w: Workout) => w.category === category)
|
||||
}
|
||||
|
||||
@@ -145,7 +123,7 @@ class SupabaseDataService {
|
||||
.eq('trainer_id', trainerId)
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching workouts by trainer:', error)
|
||||
logger.error('Error fetching workouts by trainer:', error)
|
||||
return WORKOUTS.filter((w: Workout) => w.trainerId === trainerId)
|
||||
}
|
||||
|
||||
@@ -163,7 +141,7 @@ class SupabaseDataService {
|
||||
.eq('is_featured', true)
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching featured workouts:', error)
|
||||
logger.error('Error fetching featured workouts:', error)
|
||||
return WORKOUTS.filter((w: Workout) => w.isFeatured)
|
||||
}
|
||||
|
||||
@@ -180,7 +158,7 @@ class SupabaseDataService {
|
||||
.select('*')
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching trainers:', error)
|
||||
logger.error('Error fetching trainers:', error)
|
||||
return TRAINERS
|
||||
}
|
||||
|
||||
@@ -199,7 +177,7 @@ class SupabaseDataService {
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
console.error('Error fetching trainer:', error)
|
||||
logger.error('Error fetching trainer:', error)
|
||||
return TRAINERS.find((t: Trainer) => t.id === id)
|
||||
}
|
||||
|
||||
@@ -216,7 +194,7 @@ class SupabaseDataService {
|
||||
.select('*')
|
||||
|
||||
if (collectionsError) {
|
||||
console.error('Error fetching collections:', collectionsError)
|
||||
logger.error('Error fetching collections:', collectionsError)
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -226,7 +204,7 @@ class SupabaseDataService {
|
||||
.order('sort_order')
|
||||
|
||||
if (linksError) {
|
||||
console.error('Error fetching collection workouts:', linksError)
|
||||
logger.error('Error fetching collection workouts:', linksError)
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -255,7 +233,7 @@ class SupabaseDataService {
|
||||
.single()
|
||||
|
||||
if (collectionError || !collection) {
|
||||
console.error('Error fetching collection:', collectionError)
|
||||
logger.error('Error fetching collection:', collectionError)
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -266,7 +244,7 @@ class SupabaseDataService {
|
||||
.order('sort_order')
|
||||
|
||||
if (linksError) {
|
||||
console.error('Error fetching collection workouts:', linksError)
|
||||
logger.error('Error fetching collection workouts:', linksError)
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -275,41 +253,8 @@ class SupabaseDataService {
|
||||
}
|
||||
|
||||
async getAllPrograms(): Promise<Program[]> {
|
||||
if (!isSupabaseConfigured()) {
|
||||
return Object.values(PROGRAMS)
|
||||
}
|
||||
|
||||
const { data: programsData, error: programsError } = await supabase
|
||||
.from('programs')
|
||||
.select('*')
|
||||
|
||||
if (programsError) {
|
||||
console.error('Error fetching programs:', programsError)
|
||||
return Object.values(PROGRAMS)
|
||||
}
|
||||
|
||||
const { data: workoutLinks, error: linksError } = await supabase
|
||||
.from('program_workouts')
|
||||
.select('*')
|
||||
.order('week_number')
|
||||
.order('day_number')
|
||||
|
||||
if (linksError) {
|
||||
console.error('Error fetching program workouts:', linksError)
|
||||
return Object.values(PROGRAMS)
|
||||
}
|
||||
|
||||
const workoutIdsByProgram: Record<string, string[]> = {}
|
||||
workoutLinks?.forEach((link: ProgramWorkoutRow) => {
|
||||
if (!workoutIdsByProgram[link.program_id]) {
|
||||
workoutIdsByProgram[link.program_id] = []
|
||||
}
|
||||
workoutIdsByProgram[link.program_id].push(link.workout_id)
|
||||
})
|
||||
|
||||
return programsData?.map((row: ProgramRow) =>
|
||||
mapProgramFromDB(row, workoutIdsByProgram[row.id] || [])
|
||||
) ?? Object.values(PROGRAMS)
|
||||
// Legacy programs are local only — new workout programs use workoutPrograms.ts
|
||||
return Object.values(PROGRAMS)
|
||||
}
|
||||
|
||||
async getAchievements() {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PROGRAMS, ALL_PROGRAM_WORKOUTS, ASSESSMENT_WORKOUT } from './programs'
|
||||
import { TRAINERS } from './trainers'
|
||||
import { ACHIEVEMENTS } from './achievements'
|
||||
import { WORKOUTS } from './workouts'
|
||||
import { GREEN, PHASE, TEXT, BRAND } from '../constants/colors'
|
||||
import type { ProgramId } from '../types'
|
||||
|
||||
// Re-export new program system
|
||||
@@ -77,9 +78,9 @@ export function getTrainerByName(name: string) {
|
||||
|
||||
/** Per-program accent colors (matches home screen cards) */
|
||||
const PROGRAM_ACCENT_COLORS: Record<ProgramId, string> = {
|
||||
'upper-body': '#FF6B35',
|
||||
'lower-body': '#30D158',
|
||||
'full-body': '#5AC8FA',
|
||||
'upper-body': PHASE.PREP,
|
||||
'lower-body': GREEN[500],
|
||||
'full-body': BRAND.INFO,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,7 +101,7 @@ export function getWorkoutAccentColor(workoutId: string): string {
|
||||
const programId = getWorkoutProgramId(workoutId)
|
||||
if (programId) return PROGRAM_ACCENT_COLORS[programId]
|
||||
|
||||
return '#FF6B35' // fallback
|
||||
return GREEN[500] // fallback — brand primary
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* All workouts: 8 exercises × 20s work / 10s rest = 4 minutes
|
||||
*/
|
||||
|
||||
import { Program, Assessment, ProgramId, WeekNumber, Week } from '../types/program'
|
||||
import { Program, Assessment, ProgramId, WeekNumber, Week, ProgramWorkout } from '../types/program'
|
||||
import type { WorkoutLevel, WorkoutCategory, MusicVibe } from '../types/workout'
|
||||
|
||||
type ProgramWorkoutInput = {
|
||||
@@ -19,6 +19,21 @@ type ProgramWorkoutInput = {
|
||||
tips: string[]
|
||||
}
|
||||
|
||||
type BuiltProgramWorkout = ProgramWorkout & {
|
||||
level: WorkoutLevel
|
||||
category: WorkoutCategory
|
||||
trainerId: string
|
||||
calories: number
|
||||
rounds: number
|
||||
prepTime: number
|
||||
workTime: number
|
||||
restTime: number
|
||||
musicVibe: MusicVibe
|
||||
isFeatured: boolean
|
||||
thumbnailUrl?: string
|
||||
videoUrl?: string
|
||||
}
|
||||
|
||||
/** Derive difficulty level from the week number in a progressive program */
|
||||
function getLevelFromWeek(week: number): WorkoutLevel {
|
||||
if (week <= 2) return 'Beginner'
|
||||
@@ -29,7 +44,7 @@ function getLevelFromWeek(week: number): WorkoutLevel {
|
||||
// Helper to create exercises with consistent structure
|
||||
const createExercise = (name: string, modification?: string, progression?: string) => ({
|
||||
name,
|
||||
duration: 20,
|
||||
duration: 20 as const,
|
||||
modification,
|
||||
progression,
|
||||
})
|
||||
@@ -1579,7 +1594,7 @@ export const ASSESSMENT_WORKOUT: Assessment = {
|
||||
// PROGRAM BUILDER
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function buildProgramWorkouts(inputs: ProgramWorkoutInput[], category: WorkoutCategory): any[] {
|
||||
function buildProgramWorkouts(inputs: ProgramWorkoutInput[], category: WorkoutCategory): BuiltProgramWorkout[] {
|
||||
return inputs.map((input) => ({
|
||||
...input,
|
||||
exercises: input.exercises.map((name) =>
|
||||
@@ -1600,7 +1615,7 @@ function buildProgramWorkouts(inputs: ProgramWorkoutInput[], category: WorkoutCa
|
||||
}))
|
||||
}
|
||||
|
||||
function buildWeeks(workouts: any[]): Week[] {
|
||||
function buildWeeks(workouts: BuiltProgramWorkout[]): Week[] {
|
||||
const weeks: Week[] = []
|
||||
|
||||
for (let weekNum = 1; weekNum <= 4; weekNum++) {
|
||||
|
||||
19
src/shared/data/tabata/CLAUDE.md
Normal file
19
src/shared/data/tabata/CLAUDE.md
Normal file
@@ -0,0 +1,19 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 9, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5988 | 10:14 AM | 🔴 | Fixed forward reference error in Avancé program | ~277 |
|
||||
| #5986 | 10:12 AM | 🟣 | Advanced Kine program registered and exported in data layer | ~345 |
|
||||
| #5981 | 9:56 AM | 🟣 | Bureau program registered in kiné data layer | ~309 |
|
||||
|
||||
### Apr 11, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6144 | 7:45 PM | 🔵 | Intermediaire Kine program metadata structure | ~268 |
|
||||
</claude-mem-context>
|
||||
315
src/shared/data/tabata/avance.ts
Normal file
315
src/shared/data/tabata/avance.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Programme Avancé — 4 semaines, 5 séances/semaine
|
||||
* Source: TabataKine_Guide_Complet.md — Section 4
|
||||
* Tier: PREMIUM
|
||||
*/
|
||||
|
||||
import type { TabataProgram, TabataSession, TabataWeek, TabataBlock, TabataExercise, WarmupPhase, CooldownPhase } from '../../types/program'
|
||||
|
||||
const ex = (name: string, nameEn: string, conseil: string, conseilEn: string): TabataExercise => ({ name, nameEn, conseil, conseilEn })
|
||||
const block = (id: string, odd: TabataExercise, even: TabataExercise, rounds = 8, workTime = 20, restTime = 10): TabataBlock => ({ id, oddExercise: odd, evenExercise: even, rounds, workTime, restTime })
|
||||
|
||||
// ─── Warmup / Cooldown Archetypes (from Guide Section 4) ────────
|
||||
|
||||
const WARMUP_LEGS: WarmupPhase = {
|
||||
movements: [
|
||||
{ name: 'Jogging sur place progressif', nameEn: 'Progressive Jogging in Place', duration: 60 },
|
||||
{ name: 'Leg swings dynamiques avant/arrière et latéraux', nameEn: 'Dynamic Leg Swings Front/Back and Lateral', duration: 60 },
|
||||
{ name: 'Fentes dynamiques avec rotation du tronc', nameEn: 'Dynamic Lunges with Torso Rotation', duration: 60 },
|
||||
{ name: 'Squat profond maintenu 3 sec', nameEn: 'Deep Squat Hold 3 Sec', duration: 60 },
|
||||
{ name: 'Sauts bas rapides à deux pieds', nameEn: 'Low Rapid Two-foot Jumps', duration: 60 },
|
||||
{ name: 'Clamshells rapides debout', nameEn: 'Fast Standing Clamshells', duration: 60 },
|
||||
],
|
||||
totalDuration: 360,
|
||||
}
|
||||
|
||||
const COOLDOWN_LEGS: CooldownPhase = {
|
||||
movements: [
|
||||
{ name: 'Étirement ischio-jambiers dynamique puis statique', nameEn: 'Dynamic to Static Hamstring Stretch', duration: 90 },
|
||||
{ name: 'Étirement quadriceps debout chaque jambe', nameEn: 'Standing Quad Stretch Each Leg', duration: 90 },
|
||||
{ name: 'Automassage mollets', nameEn: 'Self-massage Calves', duration: 60 },
|
||||
{ name: 'Respiration guidée récupération', nameEn: 'Guided Recovery Breathing', duration: 60 },
|
||||
],
|
||||
totalDuration: 300,
|
||||
}
|
||||
|
||||
const WARMUP_UPPER: WarmupPhase = {
|
||||
movements: [
|
||||
{ name: 'Cercles d\'épaules complets avec résistance', nameEn: 'Full Shoulder Circles with Resistance', duration: 60 },
|
||||
{ name: 'Face pulls avec élastique', nameEn: 'Face Pulls with Band', duration: 60 },
|
||||
{ name: 'Pompes lentes descente 4 secondes', nameEn: 'Slow Push-ups 4-sec Descent', duration: 60 },
|
||||
{ name: 'Dips lents sur chaise', nameEn: 'Slow Chair Dips', duration: 60 },
|
||||
{ name: 'Planche dynamique haute↔basse', nameEn: 'Dynamic High↔Low Plank', duration: 60 },
|
||||
{ name: 'Rotation thoracique en fente basse', nameEn: 'Thoracic Rotation in Lunge', duration: 60 },
|
||||
],
|
||||
totalDuration: 360,
|
||||
}
|
||||
|
||||
const COOLDOWN_UPPER: CooldownPhase = {
|
||||
movements: [
|
||||
{ name: 'Étirement rotateurs externes épaule contre mur', nameEn: 'Wall External Rotator Stretch', duration: 60 },
|
||||
{ name: 'Mobilisation thoracique sur rouleau', nameEn: 'Thoracic Foam Roller Mobilization', duration: 90 },
|
||||
{ name: 'Étirement grand pectoral profond', nameEn: 'Deep Pectoral Stretch', duration: 60 },
|
||||
{ name: 'Respiration guidée récupération', nameEn: 'Guided Recovery Breathing', duration: 60 },
|
||||
],
|
||||
totalDuration: 270,
|
||||
}
|
||||
|
||||
const WARMUP_FULL_BODY: WarmupPhase = {
|
||||
movements: [
|
||||
{ name: 'Mountain climbers modérés', nameEn: 'Moderate Mountain Climbers', duration: 60 },
|
||||
{ name: 'Squat jump amplitude partielle', nameEn: 'Partial Range Squat Jumps', duration: 60 },
|
||||
{ name: 'Burpees sans saut (sprawl)', nameEn: 'No-jump Burpees (Sprawls)', duration: 60 },
|
||||
{ name: 'High knees progressif', nameEn: 'Progressive High Knees', duration: 60 },
|
||||
{ name: 'Mobilisation chevilles et poignets', nameEn: 'Ankle and Wrist Mobility', duration: 60 },
|
||||
{ name: 'Hollow body hold 5 secondes x5', nameEn: 'Hollow Body Hold 5 Sec x5', duration: 60 },
|
||||
],
|
||||
totalDuration: 360,
|
||||
}
|
||||
|
||||
const COOLDOWN_FULL_BODY: CooldownPhase = {
|
||||
movements: [
|
||||
{ name: 'Foam roller colonne vertébrale', nameEn: 'Spinal Foam Rolling', duration: 90 },
|
||||
{ name: 'Supine twist chaque côté', nameEn: 'Supine Twist Each Side', duration: 90 },
|
||||
{ name: 'Respiration 4-7-8 × 4 cycles', nameEn: '4-7-8 Breathing × 4 Cycles', duration: 90 },
|
||||
{ name: 'Relaxation guidée', nameEn: 'Guided Relaxation', duration: 60 },
|
||||
],
|
||||
totalDuration: 330,
|
||||
}
|
||||
|
||||
const WARMUP_CORE: WarmupPhase = {
|
||||
movements: [
|
||||
{ name: 'Respiration diaphragmatique', nameEn: 'Diaphragmatic Breathing', duration: 60 },
|
||||
{ name: 'Cat-cow dynamique', nameEn: 'Dynamic Cat-Cow', duration: 60 },
|
||||
{ name: 'Rotations bassin debout', nameEn: 'Standing Pelvic Rotations', duration: 60 },
|
||||
{ name: 'Hollow body progressif 5×10 sec', nameEn: 'Progressive Hollow Body 5×10 Sec', duration: 60 },
|
||||
{ name: 'Brettzel rotation thoracique', nameEn: 'Brettzel Thoracic Rotation', duration: 60 },
|
||||
{ name: 'Squat profond asiatique', nameEn: 'Deep Asian Squat', duration: 60 },
|
||||
],
|
||||
totalDuration: 360,
|
||||
}
|
||||
|
||||
const COOLDOWN_CORE: CooldownPhase = {
|
||||
movements: [
|
||||
{ name: 'Posture de l\'enfant', nameEn: 'Child\'s Pose', duration: 60 },
|
||||
{ name: 'Torsion vertébrale au sol chaque côté', nameEn: 'Spinal Twist Each Side', duration: 60 },
|
||||
{ name: 'Étirement cobra', nameEn: 'Cobra Stretch', duration: 60 },
|
||||
{ name: 'Respiration diaphragmatique finale', nameEn: 'Final Diaphragmatic Breathing', duration: 60 },
|
||||
],
|
||||
totalDuration: 240,
|
||||
}
|
||||
|
||||
const ses = (id: string, week: number, order: number, title: string, titleEn: string, desc: string, descEn: string, focus: string[], focusEn: string[], blocks: TabataBlock[], totalRounds: number, totalDuration: number, calories: number, warmup: WarmupPhase, cooldown: CooldownPhase, eq: string[] = []): TabataSession => ({
|
||||
id, week, order, title, titleEn, description: desc, descriptionEn: descEn, focus, focusEn,
|
||||
warmup, blocks, cooldown,
|
||||
equipment: eq, totalRounds, totalDuration, calories,
|
||||
})
|
||||
|
||||
const SQUAT_JUMP_180 = ex('Squat jump 180°', '180° Squat Jump', 'Réception en rotation sollicite fortement le LCA. Antécédent de genou = rester au squat jump classique.', 'Rotational landing stresses ACL heavily. Knee history = stick to regular squat jump.')
|
||||
const FENTE_SAUTEE_EX = ex('Fente sautée', 'Jump Lunge', 'Réception absorbée sur 2–3 secondes.', 'Absorb landing over 2–3 seconds.')
|
||||
const PISTOL_FULL = ex('Pistol squat complet', 'Full Pistol Squat', 'Talon qui se soulève = manque de mobilité de cheville. Tronc qui bascule = faiblesse du moyen fessier.', 'Heel lifting = ankle mobility deficit. Torso tilting = gluteus medius weakness.')
|
||||
const NORDIC_CURL = ex('Nordic curl assisté', 'Assisted Nordic Curl', 'Cliniquement prouvé pour réduire les blessures ischio-jambiers de 50%. Même 3–4 reps contrôlées sont excellentes.', 'Clinically proven to reduce hamstring injuries by 50%. Even 3-4 controlled reps are excellent.')
|
||||
const BOX_JUMP = ex('Box jump', 'Box Jump', 'Descendre en marchant — ne pas sauter en arrière. Sauter en arrière multiplie les contraintes articulaires.', 'Step down — never jump backward. Jumping down multiplies joint stress.')
|
||||
const LATERAL_BOUND = ex('Lateral bound', 'Lateral Bound', 'Exercice de prévention des entorses par renforcement proprioceptif.', 'Sprain prevention exercise via proprioceptive strengthening.')
|
||||
const BROAD_JUMP = ex('Broad jump + recul', 'Broad Jump + Return', 'Le recul contrôlé renforce les stabilisateurs de cheville sous fatigue.', 'Controlled return strengthens ankle stabilizers under fatigue.')
|
||||
const SINGLE_LEG_DL = ex('Single leg deadlift', 'Single-leg Deadlift', 'Exercice fondamental de proprioception. Qualité > profondeur.', 'Fundamental proprioception exercise. Quality > depth.')
|
||||
const ONE_ARM_PUSHUP = ex('Pompe 1 bras assistée', 'Assisted One-arm Push-up', 'L\'asymétrie révèle les faiblesses de stabilisation scapulaire.', 'Asymmetry reveals scapular stabilization weaknesses.')
|
||||
const PIKE_PUSHUP = ex('Pike push-up', 'Pike Push-up', 'Simule le développé épaules. Contre-indiqué si syndrome d\'accrochage sous-acromial.', 'Simulates overhead press. Contraindicated with subacromial impingement.')
|
||||
const PLYO_PUSHUP = ex('Pompe plyométrique', 'Plyometric Push-up', 'Poignets en parfait alignement — échauffement spécifique non négociable.', 'Perfect wrist alignment — specific warmup non-negotiable.')
|
||||
const AROUND_WORLD = ex('Around the world planche', 'Around the World Plank', 'Ultra-exigeant pour les rotateurs de l\'épaule et les dentelés antérieurs.', 'Ultra-demanding for shoulder rotators and serratus anterior.')
|
||||
const ARCHER_FULL = ex('Archer push-up complet', 'Full Archer Push-up', 'Maintenir l\'alignement corps-bras porteur parfait.', 'Maintain perfect body-supporting arm alignment.')
|
||||
const SIDE_PLANK_ROT = ex('Planche latérale rotation', 'Side Plank with Rotation', 'Sollicite obliques, carré des lombes et rotateurs d\'épaule simultanément.', 'Targets obliques, quadratus lumborum and shoulder rotators simultaneously.')
|
||||
const PSEUDO_PLANCHE = ex('Pseudo planche push-up', 'Pseudo Planche Push-up', 'Charge extrême sur les triceps et pectoraux inférieurs.', 'Extreme load on triceps and lower pectorals.')
|
||||
const SUPERMAN_BEAT = ex('Superman battement', 'Superman Flutter', 'Travail des extenseurs dorsaux sous fatigue.', 'Thoracic extensor work under fatigue.')
|
||||
const BURPEE_TUCK = ex('Burpee saut groupé', 'Tuck Jump Burpee', 'Atterrir genoux fléchis, jamais en extension. Charge lombaire à la réception.', 'Land with bent knees, never extended. Lumbar load on landing.')
|
||||
const V_UP = ex('V-up', 'V-up', 'Si douleur au bas du dos : revenir au hollow body.', 'If lower back pain: return to hollow body.')
|
||||
const TUCK_JUMP = ex('Tuck jump', 'Tuck Jump', 'Si syndrome essuie-glace (IT band) : substituer par jumping squats.', 'If IT band syndrome: substitute with jumping squats.')
|
||||
const MC_CROISE = ex('Mountain climber croisé', 'Cross-body Mountain Climber', 'Plus difficile que le MC classique car la rotation engage les obliques.', 'Harder than regular MC as rotation engages obliques.')
|
||||
const DEVILS_PRESS = ex('Devil\'s press poids de corps', 'Bodyweight Devil\'s Press', 'Enchaînement fluide entre les phases. Pas de pause entre le grenouille et le relever.', 'Fluid transition between phases. No pause between frog and stand.')
|
||||
const BEAR_CRAWL_DEPL = ex('Bear crawl en déplacement', 'Traveling Bear Crawl', 'Gainage parfait malgré la vitesse. Genoux à 3 cm du sol.', 'Perfect core despite speed. Knees 3cm off floor.')
|
||||
const SPRAWL = ex('Sprawl', 'Sprawl', 'La projection vers le bas doit être contrôlée — ne jamais s\'écraser.', 'Downward projection must be controlled — never crash.')
|
||||
const HOLLOW_ROCK = ex('Hollow body rocks', 'Hollow Body Rocks', 'Continuité du gainage pendant le balancement est l\'objectif.', 'Core continuity during rocking is the goal.')
|
||||
const HANDSTAND_PU = ex('Handstand push-up mur', 'Wall Handstand Push-up', 'Vérifier l\'absence de douleur cervicale. Ne jamais toucher le sol avec force.', 'Verify no cervical pain. Never forcefully touch floor.')
|
||||
const SINGLE_LEG_BURPEE = ex('Single leg burpee', 'Single-leg Burpee', 'Test ultime de force, coordination et proprioception.', 'Ultimate test of strength, coordination and proprioception.')
|
||||
const DRAGON_FLAG = ex('Dragon flag partiel', 'Partial Dragon Flag', 'Ne jamais forcer si douleur lombaire. L\'exercice de Bruce Lee.', 'Never force with lower back pain. Bruce Lee\'s exercise.')
|
||||
const COPENHAGEN = ex('Copenhagen plank', 'Copenhagen Plank', 'Prévention des adducteurs la plus efficace existante.', 'Most effective adductor prevention exercise in existence.')
|
||||
const BURPEE_LATERAL = ex('Burpee + saut latéral', 'Burpee + Lateral Jump', 'Augmente l\'impact cardiovasculaire et la coordination.', 'Increases cardiovascular impact and coordination.')
|
||||
|
||||
// ─── Week 1: Bascule vers la complexité (4 blocs, 5 sessions) ──
|
||||
|
||||
const w1: TabataWeek = {
|
||||
weekNumber: 1, title: 'Bascule vers la complexité', titleEn: 'Shift to Complexity',
|
||||
description: '4 blocs/séance, ~45 min. Mouvements unilatéraux + plyométrie avancée.',
|
||||
descriptionEn: '4 blocks/session, ~45 min. Unilateral movements + advanced plyometrics.',
|
||||
focus: 'Mouvements complexes sous fatigue, unilatéral systématique', focusEn: 'Complex movements under fatigue, systematic unilateral work', isDeload: false,
|
||||
sessions: [
|
||||
ses('avc-w1-s1', 1, 1, 'Jambes explosifs', 'Explosive Legs', 'Squat 180°, pistol squat, Nordic curl, box jump.', '180° squat, pistol squat, Nordic curl, box jump.',
|
||||
['Puissance', 'Unilatéral', 'Plyométrie'], ['Power', 'Unilateral', 'Plyometrics'],
|
||||
[block('avc-w1-s1-b1', SQUAT_JUMP_180, PISTOL_FULL), block('avc-w1-s1-b2', NORDIC_CURL, LATERAL_BOUND), block('avc-w1-s1-b3', BOX_JUMP, SINGLE_LEG_DL), block('avc-w1-s1-b4', BROAD_JUMP, SUPERMAN_BEAT)],
|
||||
32, 45, 200, WARMUP_LEGS, COOLDOWN_LEGS, ['Surface surélevée stable']),
|
||||
ses('avc-w1-s2', 1, 2, 'Haut athlétique', 'Athletic Upper Body', 'Pompe 1 bras, pike push-up, pompe plyo, archer.',
|
||||
'One-arm push-up, pike push-up, plyo push-up, archer.',
|
||||
['Force', 'Stabilité scapulaire'], ['Strength', 'Scapular Stability'],
|
||||
[block('avc-w1-s2-b1', ONE_ARM_PUSHUP, PIKE_PUSHUP), block('avc-w1-s2-b2', PLYO_PUSHUP, AROUND_WORLD), block('avc-w1-s3-b3', ARCHER_FULL, SIDE_PLANK_ROT), block('avc-w1-s2-b4', PSEUDO_PLANCHE, SUPERMAN_BEAT)],
|
||||
32, 45, 185, WARMUP_UPPER, COOLDOWN_UPPER),
|
||||
ses('avc-w1-s3', 1, 3, 'Corps entier HIIT', 'Full Body HIIT', 'Burpee groupé, V-up, tuck jump, MC croisé.',
|
||||
'Tuck burpee, V-up, tuck jump, cross-body MC.',
|
||||
['Cardio max', 'Composé'], ['Max Cardio', 'Compound'],
|
||||
[block('avc-w1-s3-b1', BURPEE_TUCK, V_UP), block('avc-w1-s3-b2', TUCK_JUMP, MC_CROISE), block('avc-w1-s3-b3', DEVILS_PRESS, BEAR_CRAWL_DEPL), block('avc-w1-s3-b4', SPRAWL, HOLLOW_ROCK)],
|
||||
32, 45, 210, WARMUP_FULL_BODY, COOLDOWN_FULL_BODY),
|
||||
ses('avc-w1-s4', 1, 4, 'Gainage profond + mobilité', 'Deep Core + Mobility', 'Hollow body, Copenhagen, 90/90, Brettzel.',
|
||||
'Hollow body, Copenhagen, 90/90, Brettzel.',
|
||||
['Gainage', 'Mobilité', 'Récupération'], ['Core', 'Mobility', 'Recovery'],
|
||||
[], 0, 35, 50, WARMUP_CORE, COOLDOWN_CORE),
|
||||
ses('avc-w1-s5', 1, 5, 'MetCon 20 min AMRAP', '20-min AMRAP MetCon', '5 burpees, 10 squat jumps, 10 MC croisés, 5 pompes explosives, 10 V-ups en boucle.',
|
||||
'5 burpees, 10 squat jumps, 10 cross-body MC, 5 explosive push-ups, 10 V-ups on loop.',
|
||||
['Endurance de force', 'MetCon'], ['Strength Endurance', 'MetCon'],
|
||||
[block('avc-w1-s5-b1', BURPEE_TUCK, SQUAT_JUMP_180), block('avc-w1-s5-b2', MC_CROISE, PLYO_PUSHUP), block('avc-w1-s5-b3', V_UP, HOLLOW_ROCK), block('avc-w1-s5-b4', TUCK_JUMP, SPRAWL)],
|
||||
32, 20, 150, WARMUP_FULL_BODY, COOLDOWN_FULL_BODY),
|
||||
],
|
||||
}
|
||||
|
||||
// ─── Week 2: Densification (5 blocs, 5 sessions) ─────────────
|
||||
|
||||
const w2: TabataWeek = {
|
||||
weekNumber: 2, title: 'Densification', titleEn: 'Densification',
|
||||
description: '5 blocs/séance, ~50 min. Handstand push-up, single leg burpee, dragon flag.',
|
||||
descriptionEn: '5 blocks/session, ~50 min. Handstand push-up, single-leg burpee, dragon flag.',
|
||||
focus: 'Nouveaux mouvements très avancés, volume maximal', focusEn: 'Very advanced new movements, max volume', isDeload: false,
|
||||
sessions: [
|
||||
ses('avc-w2-s1', 2, 1, 'Jambes puissance max', 'Max Leg Power', 'Pistol + box jump + Nordic + lateral bound + broad jump.',
|
||||
'Pistol + box jump + Nordic + lateral bound + broad jump.',
|
||||
['Puissance', 'Explosivité'], ['Power', 'Explosiveness'],
|
||||
[block('avc-w2-s1-b1', PISTOL_FULL, NORDIC_CURL), block('avc-w2-s1-b2', BOX_JUMP, LATERAL_BOUND), block('avc-w2-s1-b3', BROAD_JUMP, SINGLE_LEG_DL), block('avc-w2-s1-b4', SQUAT_JUMP_180, SUPERMAN_BEAT), block('avc-w2-s1-b5', SINGLE_LEG_BURPEE, HOLLOW_ROCK)],
|
||||
40, 50, 250, WARMUP_LEGS, COOLDOWN_LEGS, ['Surface surélevée']),
|
||||
ses('avc-w2-s2', 2, 2, 'Haut force max', 'Max Upper Strength', 'Handstand PU + archer + plyo + pseudo planche + side plank.',
|
||||
'Handstand PU + archer + plyo + pseudo planche + side plank.',
|
||||
['Force maximale', 'Épaules'], ['Max Strength', 'Shoulders'],
|
||||
[block('avc-w2-s2-b1', HANDSTAND_PU, PIKE_PUSHUP), block('avc-w2-s2-b2', ARCHER_FULL, ONE_ARM_PUSHUP), block('avc-w2-s2-b3', PLYO_PUSHUP, AROUND_WORLD), block('avc-w2-s2-b4', PSEUDO_PLANCHE, SIDE_PLANK_ROT), block('avc-w2-s2-b5', DRAGON_FLAG, SUPERMAN_BEAT)],
|
||||
40, 50, 230, WARMUP_UPPER, COOLDOWN_UPPER),
|
||||
ses('avc-w2-s3', 2, 3, 'Cardio avancé', 'Advanced Cardio', 'Single leg burpee + tuck jump + devil\'s press + sprawl + bear crawl.',
|
||||
'Single-leg burpee + tuck jump + devil\'s press + sprawl + bear crawl.',
|
||||
['Cardio extrême', 'Endurance'], ['Extreme Cardio', 'Endurance'],
|
||||
[block('avc-w2-s3-b1', SINGLE_LEG_BURPEE, MC_CROISE), block('avc-w2-s3-b2', TUCK_JUMP, DEVILS_PRESS), block('avc-w2-s3-b3', SPRAWL, BEAR_CRAWL_DEPL), block('avc-w2-s3-b4', BURPEE_TUCK, V_UP), block('avc-w2-s3-b5', HOLLOW_ROCK, SQUAT_JUMP_180)],
|
||||
40, 50, 260, WARMUP_FULL_BODY, COOLDOWN_FULL_BODY),
|
||||
ses('avc-w2-s4', 2, 4, 'Gainage avancé', 'Advanced Core', 'Dragon flag + Copenhagen + hollow body + V-up + pseudo planche.',
|
||||
'Dragon flag + Copenhagen + hollow body + V-up + pseudo planche.',
|
||||
['Gainage profond', 'Stabilité'], ['Deep Core', 'Stability'],
|
||||
[block('avc-w2-s4-b1', DRAGON_FLAG, COPENHAGEN), block('avc-w2-s4-b2', HOLLOW_ROCK, V_UP), block('avc-w2-s4-b3', SIDE_PLANK_ROT, ARCHER_FULL), block('avc-w2-s4-b4', PSEUDO_PLANCHE, SUPERMAN_BEAT), block('avc-w2-s4-b5', NORDIC_CURL, HOLLOW_ROCK)],
|
||||
40, 50, 220, WARMUP_CORE, COOLDOWN_CORE),
|
||||
ses('avc-w2-s5', 2, 5, 'Mix force + puissance', 'Strength + Power Mix', 'Pistol + handstand PU + single leg burpee + plyo push-up + broad jump.',
|
||||
'Pistol + handstand PU + single-leg burpee + plyo push-up + broad jump.',
|
||||
['Force + puissance'], ['Strength + power'],
|
||||
[block('avc-w2-s5-b1', PISTOL_FULL, HANDSTAND_PU), block('avc-w2-s5-b2', SINGLE_LEG_BURPEE, PLYO_PUSHUP), block('avc-w2-s5-b3', BROAD_JUMP, DRAGON_FLAG), block('avc-w2-s5-b4', ARCHER_FULL, V_UP), block('avc-w2-s5-b5', SQUAT_JUMP_180, HOLLOW_ROCK)],
|
||||
40, 50, 255, WARMUP_FULL_BODY, COOLDOWN_FULL_BODY),
|
||||
],
|
||||
}
|
||||
|
||||
// ─── Week 3: Pic d'intensité + Complexes (5 blocs) ───────────
|
||||
|
||||
const w3: TabataWeek = {
|
||||
weekNumber: 3, title: 'Pic d\'intensité & Complexes', titleEn: 'Peak Intensity & Complexes',
|
||||
description: '5 blocs + complexes. 2 exercices enchaînés sans pause = 1 répétition.',
|
||||
descriptionEn: '5 blocks + complexes. 2 exercises chained without rest = 1 rep.',
|
||||
focus: 'Complexes, intensité maximale', focusEn: 'Complexes, maximum intensity', isDeload: false,
|
||||
sessions: [
|
||||
ses('avc-w3-s1', 3, 1, 'Complexes jambes', 'Lower Body Complexes', 'Squat jump+fente sautée, pistol+Nordic, box+jump lateral.',
|
||||
'Squat jump+jump lunge, pistol+Nordic, box+lateral jump.',
|
||||
['Complexes', 'Puissance'], ['Complexes', 'Power'],
|
||||
[block('avc-w3-s1-b1', SQUAT_JUMP_180, FENTE_SAUTEE_EX), block('avc-w3-s1-b2', PISTOL_FULL, NORDIC_CURL), block('avc-w3-s1-b3', BOX_JUMP, LATERAL_BOUND), block('avc-w3-s1-b4', BROAD_JUMP, SINGLE_LEG_DL), block('avc-w3-s1-b5', SINGLE_LEG_BURPEE, HOLLOW_ROCK)],
|
||||
40, 50, 265, WARMUP_LEGS, COOLDOWN_LEGS, ['Surface surélevée']),
|
||||
ses('avc-w3-s2', 3, 2, 'Complexes haut', 'Upper Body Complexes', 'Pompe plyo+MC croisé, archer+side plank, handstand+pike.',
|
||||
'Plyo push-up+cross MC, archer+side plank, handstand+pike.',
|
||||
['Complexes', 'Force'], ['Complexes', 'Strength'],
|
||||
[block('avc-w3-s2-b1', PLYO_PUSHUP, MC_CROISE), block('avc-w3-s2-b2', ARCHER_FULL, SIDE_PLANK_ROT), block('avc-w3-s2-b3', HANDSTAND_PU, PIKE_PUSHUP), block('avc-w3-s2-b4', ONE_ARM_PUSHUP, DRAGON_FLAG), block('avc-w3-s2-b5', PSEUDO_PLANCHE, SUPERMAN_BEAT)],
|
||||
40, 50, 245, WARMUP_UPPER, COOLDOWN_UPPER),
|
||||
ses('avc-w3-s3', 3, 3, 'Complexes corps entier', 'Full Body Complexes', 'Burpee+broad jump, sprawl+tuck jump, devil\'s press+V-up.',
|
||||
'Burpee+broad jump, sprawl+tuck jump, devil\'s press+V-up.',
|
||||
['Complexes', 'Cardio max'], ['Complexes', 'Max cardio'],
|
||||
[block('avc-w3-s3-b1', BURPEE_LATERAL, BROAD_JUMP), block('avc-w3-s3-b2', SPRAWL, TUCK_JUMP), block('avc-w3-s3-b3', DEVILS_PRESS, V_UP), block('avc-w3-s3-b4', BEAR_CRAWL_DEPL, HOLLOW_ROCK), block('avc-w3-s3-b5', SINGLE_LEG_BURPEE, SQUAT_JUMP_180)],
|
||||
40, 50, 275, WARMUP_FULL_BODY, COOLDOWN_FULL_BODY),
|
||||
ses('avc-w3-s4', 3, 4, 'Gainage + MetCon', 'Core + MetCon', 'Copenhagen+dragon flag, puis circuit rapide.',
|
||||
'Copenhagen+dragon flag, then fast circuit.',
|
||||
['Gainage', 'MetCon'], ['Core', 'MetCon'],
|
||||
[block('avc-w3-s4-b1', COPENHAGEN, DRAGON_FLAG), block('avc-w3-s4-b2', HOLLOW_ROCK, V_UP), block('avc-w3-s4-b3', BURPEE_TUCK, MC_CROISE), block('avc-w3-s4-b4', TUCK_JUMP, PLYO_PUSHUP), block('avc-w3-s4-b5', SPRAWL, NORDIC_CURL)],
|
||||
40, 50, 250, WARMUP_CORE, COOLDOWN_CORE),
|
||||
ses('avc-w3-s5', 3, 5, 'MetCon étendu 25 min', 'Extended 25-min MetCon', 'Circuit maximaliste en continu pendant 25 minutes.',
|
||||
'Maximalist continuous circuit for 25 minutes.',
|
||||
['Endurance maximale'], ['Maximum endurance'],
|
||||
[block('avc-w3-s5-b1', BURPEE_LATERAL, SQUAT_JUMP_180), block('avc-w3-s5-b2', PLYO_PUSHUP, MC_CROISE), block('avc-w3-s5-b3', BROAD_JUMP, V_UP), block('avc-w3-s5-b4', SINGLE_LEG_BURPEE, HOLLOW_ROCK), block('avc-w3-s5-b5', SPRAWL, TUCK_JUMP)],
|
||||
40, 25, 170, WARMUP_FULL_BODY, COOLDOWN_FULL_BODY),
|
||||
],
|
||||
}
|
||||
|
||||
// ─── Week 4: Décharge & tests (3 blocs, 5 sessions) ──────────
|
||||
|
||||
const w4: TabataWeek = {
|
||||
weekNumber: 4, title: 'Décharge & Tests finaux', titleEn: 'Deload & Final Tests',
|
||||
description: '3 blocs/séance. Exercices maîtrisés, focus technique. Tests de performance.',
|
||||
descriptionEn: '3 blocks/session. Mastered exercises, technique focus. Performance tests.',
|
||||
focus: 'Bilan, tests de performance, récupération', focusEn: 'Assessment, performance tests, recovery', isDeload: true,
|
||||
sessions: [
|
||||
ses('avc-w4-s1', 4, 1, 'Jambes bilan', 'Leg Assessment', 'Squat jump, pistol squat, box jump — qualité max.',
|
||||
'Squat jump, pistol squat, box jump — max quality.',
|
||||
['Bilan jambes'], ['Leg assessment'],
|
||||
[block('avc-w4-s1-b1', SQUAT_JUMP_180, PISTOL_FULL), block('avc-w4-s1-b2', BOX_JUMP, NORDIC_CURL), block('avc-w4-s1-b3', SINGLE_LEG_DL, SUPERMAN_BEAT)],
|
||||
24, 35, 170, WARMUP_LEGS, COOLDOWN_LEGS, ['Surface surélevée']),
|
||||
ses('avc-w4-s2', 4, 2, 'Haut bilan', 'Upper Assessment', 'Archer, handstand PU, plyo — qualité max.',
|
||||
'Archer, handstand PU, plyo — max quality.',
|
||||
['Bilan haut du corps'], ['Upper body assessment'],
|
||||
[block('avc-w4-s2-b1', ARCHER_FULL, HANDSTAND_PU), block('avc-w4-s2-b2', PLYO_PUSHUP, SIDE_PLANK_ROT), block('avc-w4-s2-b3', ONE_ARM_PUSHUP, DRAGON_FLAG)],
|
||||
24, 35, 155, WARMUP_UPPER, COOLDOWN_UPPER),
|
||||
ses('avc-w4-s3', 4, 3, 'Cardio bilan', 'Cardio Assessment', 'Burpee endurance 3 min + sprint.',
|
||||
'Burpee endurance 3 min + sprint.',
|
||||
['Bilan cardio'], ['Cardio assessment'],
|
||||
[block('avc-w4-s3-b1', BURPEE_TUCK, MC_CROISE), block('avc-w4-s3-b2', TUCK_JUMP, BEAR_CRAWL_DEPL), block('avc-w4-s3-b3', SPRAWL, HOLLOW_ROCK)],
|
||||
24, 35, 180, WARMUP_FULL_BODY, COOLDOWN_FULL_BODY),
|
||||
ses('avc-w4-s4', 4, 4, 'Mobilité finale', 'Final Mobility', 'Stretching complet, mobilité articulaire, respiration.',
|
||||
'Full stretching, joint mobility, breathing.',
|
||||
['Récupération', 'Mobilité'], ['Recovery', 'Mobility'],
|
||||
[], 0, 30, 40, WARMUP_CORE, COOLDOWN_CORE),
|
||||
ses('avc-w4-s5', 4, 5, 'Bilan global', 'Overall Assessment', 'Tests finaux: burpees 3min, pistol G/D, broad jump, planche+MC 2min.',
|
||||
'Final tests: 3-min burpees, L/R pistol, broad jump, plank+MC 2min.',
|
||||
['Tests de passage'], ['Passing tests'],
|
||||
[block('avc-w4-s5-b1', PISTOL_FULL, HOLLOW_ROCK), block('avc-w4-s5-b2', BURPEE_TUCK, BROAD_JUMP), block('avc-w4-s5-b3', ARCHER_FULL, COPENHAGEN)],
|
||||
24, 35, 165, WARMUP_FULL_BODY, COOLDOWN_FULL_BODY),
|
||||
],
|
||||
}
|
||||
|
||||
export const AVANCE_PROGRAM: TabataProgram = {
|
||||
id: 'avance', title: 'Avancé', titleEn: 'Advanced',
|
||||
description: 'Mouvements complexes sous fatigue, travail unilatéral systématique, préparation physique de haut niveau.',
|
||||
descriptionEn: 'Complex movements under fatigue, systematic unilateral work, high-level physical preparation.',
|
||||
tier: 'premium', accentColor: '#FF453A', icon: 'bolt',
|
||||
durationWeeks: 4, sessionsPerWeek: 5, totalSessions: 20,
|
||||
equipment: { required: [], optional: ['Surface surélevée stable', 'Mur'] },
|
||||
focusAreas: ['Plyométrie avancée', 'Unilatéral', 'Complexes', 'MetCon'],
|
||||
focusAreasEn: ['Advanced Plyometrics', 'Unilateral', 'Complexes', 'MetCon'],
|
||||
principles: [
|
||||
'Si technique s\'effondre round 5 : réduire à exercice seul',
|
||||
'Fatigue neurologique = réduire d\'une séance',
|
||||
'JAMAQUE sacrifier la technique sous fatigue',
|
||||
],
|
||||
principlesEn: [
|
||||
'If technique collapses at round 5: reduce to single exercise',
|
||||
'Neurological fatigue = reduce by one session',
|
||||
'NEVER sacrifice technique under fatigue',
|
||||
],
|
||||
completionCriteria: [
|
||||
'35+ burpees en 3 minutes',
|
||||
'5 pistol squats propres G et D',
|
||||
'Planche + MC croisé 2 minutes',
|
||||
'Broad jump progression vs test intermédiaire',
|
||||
'Asymétrie G/D < 20% partout',
|
||||
],
|
||||
completionCriteriaEn: [
|
||||
'35+ burpees in 3 minutes',
|
||||
'5 clean pistol squats L and R',
|
||||
'Plank + cross MC for 2 minutes',
|
||||
'Broad jump improvement vs intermediate test',
|
||||
'L/R asymmetry < 20% everywhere',
|
||||
],
|
||||
weeks: [w1, w2, w3, w4],
|
||||
}
|
||||
|
||||
export const AVANCE_SESSIONS: TabataSession[] = AVANCE_PROGRAM.weeks.flatMap(w => w.sessions)
|
||||
683
src/shared/data/tabata/bureau.ts
Normal file
683
src/shared/data/tabata/bureau.ts
Normal file
@@ -0,0 +1,683 @@
|
||||
/**
|
||||
* Programme Bureau — 4 semaines, 3-4 séances/semaine
|
||||
* Source: TabataKine_Guide_Complet.md, Section 7
|
||||
* Tier: PREMIUM
|
||||
*
|
||||
* Contraintes de conception:
|
||||
* - Silence : pas de sauts, pas d'impacts, pas de chutes au sol
|
||||
* - Pas de sol : tous les exercices debout ou assis sur une chaise
|
||||
* - Pas de sueur visible : intensite calibree
|
||||
* - 10 ou 20 min max
|
||||
* - Tenue de bureau : aucun equipement
|
||||
*
|
||||
* 3 formats:
|
||||
* - Format A (10 min, 4 blocs de 2 min = 8 rounds): Assis/desk exercises
|
||||
* - Format B (20 min, 5 blocs de 4 min = 40 rounds): Standing exercises
|
||||
* - Format C (20 min, walking meeting): Audio-guided walking
|
||||
*
|
||||
* Semaine 4 = decharge + autonomie
|
||||
*/
|
||||
|
||||
import type { TabataProgram, TabataSession, TabataWeek, TabataBlock, TabataExercise } from '../../types/program'
|
||||
|
||||
// ─── Exercise helpers ──────────────────────────────────────────
|
||||
|
||||
const ex = (
|
||||
name: string, nameEn: string,
|
||||
conseil: string, conseilEn: string,
|
||||
opts?: { modification?: string; modificationEn?: string; progression?: string; progressionEn?: string }
|
||||
): TabataExercise => ({
|
||||
name, nameEn, conseil, conseilEn, ...opts,
|
||||
})
|
||||
|
||||
const block = (
|
||||
id: string,
|
||||
odd: TabataExercise,
|
||||
even: TabataExercise,
|
||||
rounds = 8,
|
||||
workTime = 20,
|
||||
restTime = 10,
|
||||
): TabataBlock => ({
|
||||
id, oddExercise: odd, evenExercise: even, rounds, workTime, restTime,
|
||||
})
|
||||
|
||||
// ─── Format A Exercises (Seated / Desk) ────────────────────────
|
||||
|
||||
const CONTRACTION_ABDO_ISO = ex(
|
||||
'Contraction abdominale isometrique', 'Isometric Abdominal Contraction',
|
||||
"C'est le transverse. Le contracter regulierement en position assise combat directement les douleurs lombaires liees a la sedentarite.",
|
||||
'This targets the transverse abdominis. Regular contraction while seated directly fights lower back pain from sedentary habits.',
|
||||
{
|
||||
modification: 'Contracter sur 5 sec au lieu de 20',
|
||||
modificationEn: 'Contract for 5 sec instead of 20',
|
||||
progression: 'Ajouter une inclinaison du bassin en retroversion',
|
||||
progressionEn: 'Add a posterior pelvic tilt',
|
||||
},
|
||||
)
|
||||
|
||||
const SERRAGE_FESSIER = ex(
|
||||
'Serrage fessier (assis)', 'Seated Glute Squeeze',
|
||||
'Contre la retroversion du bassin liee a la position assise prolongee. Invisible de l\'exterieur.',
|
||||
'Counteracts pelvic posterior tilt from prolonged sitting. Invisible to others.',
|
||||
{
|
||||
modification: 'Serrer un seul cote a la fois',
|
||||
modificationEn: 'Squeeze one side at a time',
|
||||
progression: 'Serrer + soulever legerement le bassin du siege',
|
||||
progressionEn: 'Squeeze + slightly lift pelvis off the seat',
|
||||
},
|
||||
)
|
||||
|
||||
const EXTENSION_JAMBE_ASSIS = ex(
|
||||
'Extension de jambe (assis)', 'Seated Leg Extension',
|
||||
'Renforcement du quadriceps. Ajouter une contraction du pied (orteils vers soi) pour activer les tibias anterieurs.',
|
||||
'Quadriceps strengthening. Add a foot contraction (toes toward you) to activate tibialis anterior.',
|
||||
{
|
||||
modification: 'Extension partielle, jambe a moitie',
|
||||
modificationEn: 'Partial extension, leg halfway up',
|
||||
progression: 'Maintenir 3 sec en haut + flexion du pied',
|
||||
progressionEn: 'Hold 3 sec at top + dorsiflex foot',
|
||||
},
|
||||
)
|
||||
|
||||
const ELEVATION_TALONS_ASSIS = ex(
|
||||
'Elevation de talons assis', 'Seated Heel Raise',
|
||||
'Active la pompe veineuse des jambes. Prevention des jambes lourdes et des varices.',
|
||||
'Activates the leg venous pump. Prevents heavy legs and varicose veins.',
|
||||
{
|
||||
modification: 'Elever un talon a la fois',
|
||||
modificationEn: 'Raise one heel at a time',
|
||||
progression: 'Elever les deux talons + maintenir 3 sec en haut',
|
||||
progressionEn: 'Raise both heels + hold 3 sec at top',
|
||||
},
|
||||
)
|
||||
|
||||
const WALL_SIT = ex(
|
||||
'Wall sit (chaise invisible)', 'Wall Sit',
|
||||
'Aucun bruit, aucun mouvement visible de l\'exterieur, intensite maximale pour les quadriceps. Respirer calmement.',
|
||||
'No noise, no visible movement from outside, maximum intensity for quadriceps. Breathe calmly.',
|
||||
{
|
||||
modification: 'Angle de 135° au lieu de 90°',
|
||||
modificationEn: '135° angle instead of 90°',
|
||||
progression: 'Ajouter une contraction abdo en position basse',
|
||||
progressionEn: 'Add an ab contraction at the bottom',
|
||||
},
|
||||
)
|
||||
|
||||
const ELEVATION_TALONS_DEBOUT = ex(
|
||||
'Elevation de talons debout', 'Standing Heel Raise',
|
||||
'Renforcement des soleaires et gastrocnemiens. Bras tendus devant soi pour l\'equilibre si necessaire.',
|
||||
'Strengthens soleus and gastrocnemius. Arms extended forward for balance if needed.',
|
||||
{
|
||||
modification: 'Tenir un support pour l\'equilibre',
|
||||
modificationEn: 'Hold a support for balance',
|
||||
progression: 'Unilateral — un pied a la fois',
|
||||
progressionEn: 'Unilateral — one foot at a time',
|
||||
},
|
||||
)
|
||||
|
||||
const POMPES_BUREAU = ex(
|
||||
'Pompes contre le bureau', 'Desk Push-ups',
|
||||
'S\'assurer que le bureau est stable avant de commencer. Plus incline = plus facile.',
|
||||
'Make sure the desk is stable before starting. More incline = easier.',
|
||||
{
|
||||
modification: 'Mains sur le mur (plus vertical)',
|
||||
modificationEn: 'Hands on wall (more vertical)',
|
||||
progression: 'Mains sur un support plus bas (chaise)',
|
||||
progressionEn: 'Hands on a lower surface (chair)',
|
||||
},
|
||||
)
|
||||
|
||||
const PULL_APART = ex(
|
||||
'Pull apart imaginaire', 'Imaginary Pull Apart',
|
||||
'Contre le syndrome de l\'epaule roulee du bureau. Ouvrir la poitrine au maximum.',
|
||||
'Counteracts desk slouch / rounded shoulders. Open the chest as wide as possible.',
|
||||
{
|
||||
modification: 'Bras plies a 90° au lieu de tendus',
|
||||
modificationEn: 'Bent arms at 90° instead of straight',
|
||||
progression: 'Ajouter une rotation externe des paumes vers le plafond',
|
||||
progressionEn: 'Add external rotation of palms toward ceiling',
|
||||
},
|
||||
)
|
||||
|
||||
// ─── Format B Exercises (Standing, Zero Impact) ────────────────
|
||||
|
||||
const SQUAT_SILENCIEUX = ex(
|
||||
'Squat silencieux tempo 3-3', 'Silent Squat Tempo 3-3',
|
||||
'Tempo 3 sec descente / 3 sec montee pour rester sous le seuil de transpiration tout en maintenant l\'efficacite musculaire.',
|
||||
'3 sec down / 3 sec up tempo to stay below the sweat threshold while maintaining muscle effectiveness.',
|
||||
{
|
||||
modification: 'Squat 1/4 avec tempo 2-2',
|
||||
modificationEn: 'Quarter squat with 2-2 tempo',
|
||||
progression: 'Ajouter 1 sec de maintien en bas',
|
||||
progressionEn: 'Add 1 sec hold at the bottom',
|
||||
},
|
||||
)
|
||||
|
||||
const FENTE_STATIQUE_ROTATION = ex(
|
||||
'Fente statique avec rotation de buste', 'Static Lunge with Torso Rotation',
|
||||
'Fente basse tenue + rotation du tronc vers le genou avant. Mobilite thoracique et renforcement simultanes.',
|
||||
'Hold low lunge + rotate torso toward front knee. Thoracic mobility and strengthening simultaneously.',
|
||||
{
|
||||
modification: 'Fente haute (angle reduit)',
|
||||
modificationEn: 'High lunge (reduced angle)',
|
||||
progression: 'Ajouter les bras tendus au-dessus de la tete pendant la rotation',
|
||||
progressionEn: 'Add arms extended overhead during rotation',
|
||||
},
|
||||
)
|
||||
|
||||
const BUREAU_DIPS = ex(
|
||||
'Bureau dips', 'Desk Dips',
|
||||
'Mains sur le bureau derriere soi, corps decolle, flechir les coudes. Verifier la stabilite du bureau avant de commencer.',
|
||||
'Hands on desk behind you, body lifted, bend elbows. Check desk stability before starting.',
|
||||
{
|
||||
modification: 'Jambes pliees, pieds plus proches',
|
||||
modificationEn: 'Bent legs, feet closer',
|
||||
progression: 'Jambes tendues, pieds plus eloignes',
|
||||
progressionEn: 'Straight legs, feet further out',
|
||||
},
|
||||
)
|
||||
|
||||
const CALF_RAISES_FERMES = ex(
|
||||
'Calf raises yeux fermes', 'Eyes-closed Calf Raises',
|
||||
'Montees sur la pointe des pieds avec les yeux fermes. Double effet : renforcement + proprioception.',
|
||||
'Calf raises with eyes closed. Double benefit: strengthening + proprioception.',
|
||||
{
|
||||
modification: 'Garder les yeux ouverts, tenir un support',
|
||||
modificationEn: 'Keep eyes open, hold a support',
|
||||
progression: 'Unilateral — un pied a la fois, yeux fermes',
|
||||
progressionEn: 'Unilateral — one foot at a time, eyes closed',
|
||||
},
|
||||
)
|
||||
|
||||
const ISOMETRIE_FESSIERS_DEBOUT = ex(
|
||||
'Isometrie fessiers debout', 'Standing Glute Isometric Hold',
|
||||
'Contracter les fessiers alternativement sans bouger les jambes. Activation du moyen fessier inhibe par la position assise.',
|
||||
'Contract glutes alternately without moving legs. Activates gluteus medius inhibited by prolonged sitting.',
|
||||
{
|
||||
modification: 'Contractions rapides (2 sec) au lieu de maintenues',
|
||||
modificationEn: 'Quick contractions (2 sec) instead of holds',
|
||||
progression: 'Ajouter une micro-elevee du talon du cote actif',
|
||||
progressionEn: 'Add a micro heel lift on the active side',
|
||||
},
|
||||
)
|
||||
|
||||
const ROTATION_BUSTE_RAPIDE = ex(
|
||||
'Rotation de buste rapide', 'Fast Torso Rotation',
|
||||
'Bras croises sur la poitrine, rotation droite-gauche rapide. Active les obliques et echauffe les disques intervertebraux.',
|
||||
'Arms crossed on chest, fast right-left rotation. Activates obliques and warms up intervertebral discs.',
|
||||
{
|
||||
modification: 'Rotation lente et controlee',
|
||||
modificationEn: 'Slow and controlled rotation',
|
||||
progression: 'Bras tendus sur les cotes pour augmenter l\'amplitude',
|
||||
progressionEn: 'Arms extended to sides for greater range of motion',
|
||||
},
|
||||
)
|
||||
|
||||
// ─── Format C Exercises (Walking / Audio-only) ─────────────────
|
||||
|
||||
const MARCHE_RAPIDE = ex(
|
||||
'Marche rapide', 'Brisk Walking',
|
||||
'La marche active a 6-7 km/h suffit a atteindre 70-80% de la FC max. Amplement suffisant pour les benefices cardiovasculaires.',
|
||||
'Active walking at 6-7 km/h reaches 70-80% of max HR. Sufficient for cardiovascular HIIT benefits.',
|
||||
)
|
||||
|
||||
const MARCHE_GENOUX_HAUTS = ex(
|
||||
'Marche genoux hauts', 'High-knee Walking',
|
||||
'Lever les genoux a hauteur des hanches en marchant. Intensite elevee sans impact.',
|
||||
'Lift knees to hip height while walking. High intensity without impact.',
|
||||
)
|
||||
|
||||
const MARCHE_FENTE = ex(
|
||||
'Marche en fente', 'Lunge Walking',
|
||||
'Pas le plus long possible. Tronc droit, genou avant stable.',
|
||||
'Longest stride possible. Torso upright, front knee stable.',
|
||||
)
|
||||
|
||||
const MONTEE_ESCALIERS = ex(
|
||||
'Montee d\'escaliers', 'Stair Climbing',
|
||||
'Si disponibles. Monter en marchant rapide, descendre en marchant normale.',
|
||||
'If available. Walk up briskly, walk down normally.',
|
||||
)
|
||||
|
||||
const MARCHE_NORMALE = ex(
|
||||
'Marche normale (recuperation)', 'Normal Walking (recovery)',
|
||||
'Marche naturelle pour recuperation active entre les phases intenses.',
|
||||
'Natural walking for active recovery between intense phases.',
|
||||
)
|
||||
|
||||
// ─── Format A Warmup & Cooldown ────────────────────────────────
|
||||
|
||||
const FORMAT_A_WARMUP = {
|
||||
movements: [
|
||||
{ name: 'Cercles de poignets dans les deux sens', nameEn: 'Wrist Circles Both Directions', duration: 20 },
|
||||
{ name: 'Mobilisation cervicale : inclinaisons et rotations douces', nameEn: 'Neck Mobilization: Gentle Tilts & Rotations', duration: 20 },
|
||||
{ name: 'Ouverture de poitrine : bras en arriere, omoplates rapprochees', nameEn: 'Chest Opener: Arms Back, Squeeze Shoulder Blades', duration: 20 },
|
||||
],
|
||||
totalDuration: 60,
|
||||
}
|
||||
|
||||
const FORMAT_A_COOLDOWN = {
|
||||
movements: [
|
||||
{ name: 'Etirement cervical lateral doux (chaque cote)', nameEn: 'Gentle Lateral Neck Stretch (each side)', duration: 15 },
|
||||
{ name: 'Etirement des poignets (extenseurs et flechisseurs)', nameEn: 'Wrist Stretch (extensors and flexors)', duration: 15 },
|
||||
{ name: '3 grandes respirations diaphragmatiques', nameEn: '3 Deep Diaphragmatic Breaths', duration: 30 },
|
||||
],
|
||||
totalDuration: 60,
|
||||
}
|
||||
|
||||
// ─── Format B Warmup & Cooldown ────────────────────────────────
|
||||
|
||||
const FORMAT_B_WARMUP = {
|
||||
movements: [
|
||||
{ name: 'Marche sur place avec bras actifs', nameEn: 'March in Place with Active Arms', duration: 60 },
|
||||
{ name: 'Cercles de hanches debout', nameEn: 'Standing Hip Circles', duration: 30 },
|
||||
{ name: 'Squats lents d\'activation x8', nameEn: 'Slow Activation Squats x8', duration: 45 },
|
||||
{ name: 'Rotations d\'epaules avant et arriere', nameEn: 'Shoulder Circles Forward & Back', duration: 30 },
|
||||
{ name: 'Respiration diaphragmatique', nameEn: 'Diaphragmatic Breathing', duration: 15 },
|
||||
],
|
||||
totalDuration: 180,
|
||||
}
|
||||
|
||||
const FORMAT_B_COOLDOWN = {
|
||||
movements: [
|
||||
{ name: 'Etirement quadriceps debout (chaque jambe)', nameEn: 'Standing Quad Stretch (each leg)', duration: 30 },
|
||||
{ name: 'Etirement des mollets contre un mur (chaque jambe)', nameEn: 'Wall Calf Stretch (each leg)', duration: 30 },
|
||||
{ name: 'Etirement pectoraux en porte', nameEn: 'Doorway Chest Stretch', duration: 30 },
|
||||
{ name: 'Rotation cervicale lente', nameEn: 'Slow Neck Rotation', duration: 15 },
|
||||
{ name: 'Respiration 4-7-8 x 3 cycles', nameEn: '4-7-8 Breathing x 3 cycles', duration: 57 },
|
||||
],
|
||||
totalDuration: 162,
|
||||
}
|
||||
|
||||
// ─── Format C Warmup & Cooldown ────────────────────────────────
|
||||
|
||||
const FORMAT_C_WARMUP = {
|
||||
movements: [
|
||||
{ name: 'Marche normale d\'echauffement', nameEn: 'Normal Walking Warmup', duration: 60 },
|
||||
{ name: 'Rotations d\'epaules en marchant', nameEn: 'Shoulder Rotations While Walking', duration: 30 },
|
||||
{ name: 'Marche avec montees de genoux lentes', nameEn: 'Walking with Slow High Knees', duration: 30 },
|
||||
],
|
||||
totalDuration: 120,
|
||||
}
|
||||
|
||||
const FORMAT_C_COOLDOWN = {
|
||||
movements: [
|
||||
{ name: 'Marche normale de retour au calme', nameEn: 'Normal Walking Cooldown', duration: 60 },
|
||||
{ name: 'Etirement des mollets en marchant', nameEn: 'Walking Calf Stretches', duration: 30 },
|
||||
{ name: 'Respiration profonde en marchant', nameEn: 'Deep Breathing While Walking', duration: 30 },
|
||||
],
|
||||
totalDuration: 120,
|
||||
}
|
||||
|
||||
// ─── Session Builders ──────────────────────────────────────────
|
||||
|
||||
/** Format A session: 10 min, 4 blocks of 2 min (8 rounds each) */
|
||||
const formatASession = (
|
||||
id: string, week: number, order: number,
|
||||
title: string, titleEn: string,
|
||||
description: string, descriptionEn: string,
|
||||
focus: string[], focusEn: string[],
|
||||
b1Odd: TabataExercise, b1Even: TabataExercise,
|
||||
b2Odd: TabataExercise, b2Even: TabataExercise,
|
||||
b3Odd: TabataExercise, b3Even: TabataExercise,
|
||||
b4Odd: TabataExercise, b4Even: TabataExercise,
|
||||
calories: number,
|
||||
): TabataSession => ({
|
||||
id, week, order, title, titleEn, description, descriptionEn, focus, focusEn,
|
||||
warmup: FORMAT_A_WARMUP,
|
||||
blocks: [
|
||||
block(`${id}-b1`, b1Odd, b1Even),
|
||||
block(`${id}-b2`, b2Odd, b2Even),
|
||||
block(`${id}-b3`, b3Odd, b3Even),
|
||||
block(`${id}-b4`, b4Odd, b4Even),
|
||||
],
|
||||
cooldown: FORMAT_A_COOLDOWN,
|
||||
equipment: ['Chaise', 'Mur (optionnel)', 'Bureau stable'],
|
||||
totalRounds: 32,
|
||||
totalDuration: 10,
|
||||
calories,
|
||||
})
|
||||
|
||||
/** Format B session: 20 min, 5 blocks of 4 min (8 rounds each) */
|
||||
const formatBSession = (
|
||||
id: string, week: number, order: number,
|
||||
title: string, titleEn: string,
|
||||
description: string, descriptionEn: string,
|
||||
focus: string[], focusEn: string[],
|
||||
b1Odd: TabataExercise, b1Even: TabataExercise,
|
||||
b2Odd: TabataExercise, b2Even: TabataExercise,
|
||||
b3Odd: TabataExercise, b3Even: TabataExercise,
|
||||
b4Odd: TabataExercise, b4Even: TabataExercise,
|
||||
b5Odd: TabataExercise, b5Even: TabataExercise,
|
||||
calories: number,
|
||||
): TabataSession => ({
|
||||
id, week, order, title, titleEn, description, descriptionEn, focus, focusEn,
|
||||
warmup: FORMAT_B_WARMUP,
|
||||
blocks: [
|
||||
block(`${id}-b1`, b1Odd, b1Even),
|
||||
block(`${id}-b2`, b2Odd, b2Even),
|
||||
block(`${id}-b3`, b3Odd, b3Even),
|
||||
block(`${id}-b4`, b4Odd, b4Even),
|
||||
block(`${id}-b5`, b5Odd, b5Even),
|
||||
],
|
||||
cooldown: FORMAT_B_COOLDOWN,
|
||||
equipment: ['Bureau stable', 'Mur (optionnel)'],
|
||||
totalRounds: 40,
|
||||
totalDuration: 20,
|
||||
calories,
|
||||
})
|
||||
|
||||
/** Format C session: 20 min walking meeting, 5 blocks of 4 min */
|
||||
const formatCSession = (
|
||||
id: string, week: number, order: number,
|
||||
title: string, titleEn: string,
|
||||
description: string, descriptionEn: string,
|
||||
focus: string[], focusEn: string[],
|
||||
calories: number,
|
||||
): TabataSession => ({
|
||||
id, week, order, title, titleEn, description, descriptionEn, focus, focusEn,
|
||||
warmup: FORMAT_C_WARMUP,
|
||||
blocks: [
|
||||
block(`${id}-b1`, MARCHE_RAPIDE, MARCHE_NORMALE),
|
||||
block(`${id}-b2`, MARCHE_GENOUX_HAUTS, MARCHE_NORMALE),
|
||||
block(`${id}-b3`, MARCHE_FENTE, MARCHE_RAPIDE),
|
||||
block(`${id}-b4`, MONTEE_ESCALIERS, MARCHE_NORMALE),
|
||||
block(`${id}-b5`, MARCHE_RAPIDE, MARCHE_NORMALE),
|
||||
],
|
||||
cooldown: FORMAT_C_COOLDOWN,
|
||||
equipment: [],
|
||||
totalRounds: 40,
|
||||
totalDuration: 20,
|
||||
calories,
|
||||
})
|
||||
|
||||
// ─── Week 1: Creer l'habitude (3x Format A) ───────────────────
|
||||
|
||||
const week1: TabataWeek = {
|
||||
weekNumber: 1,
|
||||
title: 'Creer l\'habitude de la pause active',
|
||||
titleEn: 'Building the Active Break Habit',
|
||||
description: '3 seances Format A (10 min chacune). Mouvement assis et debout, parfaitement discret au bureau. Aucun equipement necessaire.',
|
||||
descriptionEn: '3 Format A sessions (10 min each). Seated and standing movement, perfectly discreet at the office. No equipment needed.',
|
||||
focus: 'Integration du mouvement dans la journee de travail',
|
||||
focusEn: 'Integrating movement into the workday',
|
||||
isDeload: false,
|
||||
sessions: [
|
||||
// W1-S1 — Format A: Activation profonde + membres inferieurs
|
||||
formatASession(
|
||||
'bur-w1-s1', 1, 1,
|
||||
'Pause active assise', 'Seated Active Break',
|
||||
'Activation profonde (assis) et membres inferieurs assis. Parfaitement discret.',
|
||||
'Deep activation (seated) and seated lower body. Perfectly discreet.',
|
||||
['Transverse', 'Quadriceps', 'Mollets'],
|
||||
['Transverse Abdominis', 'Quadriceps', 'Calves'],
|
||||
CONTRACTION_ABDO_ISO, SERRAGE_FESSIER,
|
||||
EXTENSION_JAMBE_ASSIS, ELEVATION_TALONS_ASSIS,
|
||||
WALL_SIT, ELEVATION_TALONS_DEBOUT,
|
||||
POMPES_BUREAU, PULL_APART,
|
||||
35,
|
||||
),
|
||||
// W1-S2 — Format A: Renforcement jambes + haut du corps
|
||||
formatASession(
|
||||
'bur-w1-s2', 1, 2,
|
||||
'Renforcement bureau', 'Desk Strength',
|
||||
'Extension de jambes, wall sit et pompes bureau. Seance equilibree pour toute la journee.',
|
||||
'Leg extensions, wall sits and desk push-ups. Balanced session for the whole day.',
|
||||
['Quadriceps', 'Fessiers', 'Pectoraux'],
|
||||
['Quadriceps', 'Glutes', 'Chest'],
|
||||
SERRAGE_FESSIER, CONTRACTION_ABDO_ISO,
|
||||
ELEVATION_TALONS_ASSIS, EXTENSION_JAMBE_ASSIS,
|
||||
ELEVATION_TALONS_DEBOUT, WALL_SIT,
|
||||
PULL_APART, POMPES_BUREAU,
|
||||
38,
|
||||
),
|
||||
// W1-S3 — Format A: Variante equilibree
|
||||
formatASession(
|
||||
'bur-w1-s3', 1, 3,
|
||||
'Equilibre bureau', 'Desk Balance',
|
||||
'Melange des exercices de la semaine pour consolider les mouvements.',
|
||||
'Mix of the week\'s exercises to consolidate movements.',
|
||||
['Gainage', 'Mollets', 'Dos'],
|
||||
['Core', 'Calves', 'Back'],
|
||||
CONTRACTION_ABDO_ISO, SERRAGE_FESSIER,
|
||||
EXTENSION_JAMBE_ASSIS, ELEVATION_TALONS_ASSIS,
|
||||
WALL_SIT, ELEVATION_TALONS_DEBOUT,
|
||||
PULL_APART, POMPES_BUREAU,
|
||||
36,
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
// ─── Week 2: Introduction du format long (2x A + 1x B) ────────
|
||||
|
||||
const week2: TabataWeek = {
|
||||
weekNumber: 2,
|
||||
title: 'Introduction du format long',
|
||||
titleEn: 'Introducing the Longer Format',
|
||||
description: '2 seances Format A (10 min) + 1 seance Format B (20 min). Le Format B introduit des exercices debout plus intenses.',
|
||||
descriptionEn: '2 Format A sessions (10 min) + 1 Format B session (20 min). Format B introduces more intense standing exercises.',
|
||||
focus: 'Progression vers le format 20 minutes, exercices debout',
|
||||
focusEn: 'Progressing to the 20-minute format, standing exercises',
|
||||
isDeload: false,
|
||||
sessions: [
|
||||
// W2-S1 — Format A (assis)
|
||||
formatASession(
|
||||
'bur-w2-s1', 2, 1,
|
||||
'Pause rapide assise', 'Quick Seated Break',
|
||||
'Format A renforce. Les contractions isometriques deviennent plus naturelles.',
|
||||
'Consolidated Format A. Isometric contractions become more natural.',
|
||||
['Transverse', 'Quadriceps', 'Epaules'],
|
||||
['Transverse Abdominis', 'Quadriceps', 'Shoulders'],
|
||||
CONTRACTION_ABDO_ISO, SERRAGE_FESSIER,
|
||||
EXTENSION_JAMBE_ASSIS, ELEVATION_TALONS_ASSIS,
|
||||
WALL_SIT, ELEVATION_TALONS_DEBOUT,
|
||||
POMPES_BUREAU, PULL_APART,
|
||||
37,
|
||||
),
|
||||
// W2-S2 — Format A (variant)
|
||||
formatASession(
|
||||
'bur-w2-s2', 2, 2,
|
||||
'Renforcement silencieux', 'Silent Strengthening',
|
||||
'Derniere seance Format A de la semaine. Focus sur la qualite des contractions.',
|
||||
'Last Format A session of the week. Focus on contraction quality.',
|
||||
['Fessiers', 'Pectoraux', 'Mollets'],
|
||||
['Glutes', 'Chest', 'Calves'],
|
||||
SERRAGE_FESSIER, CONTRACTION_ABDO_ISO,
|
||||
ELEVATION_TALONS_ASSIS, EXTENSION_JAMBE_ASSIS,
|
||||
ELEVATION_TALONS_DEBOUT, WALL_SIT,
|
||||
PULL_APART, POMPES_BUREAU,
|
||||
38,
|
||||
),
|
||||
// W2-S3 — Format B (20 min debout)
|
||||
formatBSession(
|
||||
'bur-w2-s3', 2, 3,
|
||||
'Format long debout', 'Long Standing Session',
|
||||
'Premiere seance Format B. 20 minutes debout avec squats silencieux, fentes et dips bureau.',
|
||||
'First Format B session. 20 minutes standing with silent squats, lunges and desk dips.',
|
||||
['Quadriceps', 'Triceps', 'Proprioception', 'Obliques'],
|
||||
['Quadriceps', 'Triceps', 'Proprioception', 'Obliques'],
|
||||
SQUAT_SILENCIEUX, FENTE_STATIQUE_ROTATION,
|
||||
BUREAU_DIPS, CALF_RAISES_FERMES,
|
||||
ISOMETRIE_FESSIERS_DEBOUT, ROTATION_BUSTE_RAPIDE,
|
||||
SQUAT_SILENCIEUX, ELEVATION_TALONS_DEBOUT,
|
||||
ROTATION_BUSTE_RAPIDE, ISOMETRIE_FESSIERS_DEBOUT,
|
||||
90,
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
// ─── Week 3: Walking meeting integre (1x A + 2x B + 1x C) ────
|
||||
|
||||
const week3: TabataWeek = {
|
||||
weekNumber: 3,
|
||||
title: 'Walking meeting integre',
|
||||
titleEn: 'Walking Meeting Integrated',
|
||||
description: '1 Format A + 2 Format B + 1 Format C (walking meeting audio). Seule semaine a 4 seances.',
|
||||
descriptionEn: '1 Format A + 2 Format B + 1 Format C (audio walking meeting). Only week with 4 sessions.',
|
||||
focus: 'Introduction de la marche active, autonomie dans les formats',
|
||||
focusEn: 'Introducing active walking, autonomy across formats',
|
||||
isDeload: false,
|
||||
sessions: [
|
||||
// W3-S1 — Format A (rappel)
|
||||
formatASession(
|
||||
'bur-w3-s1', 3, 1,
|
||||
'Pause express bureau', 'Express Desk Break',
|
||||
'Dernier Format A du programme. Tous les exercices doivent etre fluides.',
|
||||
'Last Format A of the program. All exercises should feel fluid.',
|
||||
['Transverse', 'Quadriceps', 'Haut du corps'],
|
||||
['Transverse Abdominis', 'Quadriceps', 'Upper Body'],
|
||||
CONTRACTION_ABDO_ISO, SERRAGE_FESSIER,
|
||||
EXTENSION_JAMBE_ASSIS, ELEVATION_TALONS_ASSIS,
|
||||
WALL_SIT, ELEVATION_TALONS_DEBOUT,
|
||||
POMPES_BUREAU, PULL_APART,
|
||||
38,
|
||||
),
|
||||
// W3-S2 — Format B (renforcement)
|
||||
formatBSession(
|
||||
'bur-w3-s2', 3, 2,
|
||||
'Renforcement standing', 'Standing Strength',
|
||||
'Format B renforce. Squats silencieux, fentes avec rotation et proprioception.',
|
||||
'Consolidated Format B. Silent squats, lunges with rotation and proprioception.',
|
||||
['Quadriceps', 'Triceps', 'Mobilite thoracique', 'Equilibre'],
|
||||
['Quadriceps', 'Triceps', 'Thoracic Mobility', 'Balance'],
|
||||
SQUAT_SILENCIEUX, FENTE_STATIQUE_ROTATION,
|
||||
BUREAU_DIPS, CALF_RAISES_FERMES,
|
||||
ISOMETRIE_FESSIERS_DEBOUT, ROTATION_BUSTE_RAPIDE,
|
||||
WALL_SIT, SQUAT_SILENCIEUX,
|
||||
PULL_APART, ROTATION_BUSTE_RAPIDE,
|
||||
92,
|
||||
),
|
||||
// W3-S3 — Format B (intensite)
|
||||
formatBSession(
|
||||
'bur-w3-s3', 3, 3,
|
||||
'Intensite bureau avancee', 'Advanced Desk Intensity',
|
||||
'Format B intense. Plus de proprioception et de mobilite thoracique.',
|
||||
'Intense Format B. More proprioception and thoracic mobility.',
|
||||
['Fessiers', 'Obliques', 'Proprioception', 'Dos'],
|
||||
['Glutes', 'Obliques', 'Proprioception', 'Back'],
|
||||
FENTE_STATIQUE_ROTATION, SQUAT_SILENCIEUX,
|
||||
CALF_RAISES_FERMES, BUREAU_DIPS,
|
||||
ROTATION_BUSTE_RAPIDE, ISOMETRIE_FESSIERS_DEBOUT,
|
||||
ELEVATION_TALONS_DEBOUT, WALL_SIT,
|
||||
ISOMETRIE_FESSIERS_DEBOUT, ROTATION_BUSTE_RAPIDE,
|
||||
95,
|
||||
),
|
||||
// W3-S4 — Format C (walking meeting)
|
||||
formatCSession(
|
||||
'bur-w3-s4', 3, 4,
|
||||
'Walking meeting actif', 'Active Walking Meeting',
|
||||
'Seance audio guidee pendant une marche. Marche rapide, genoux hauts, marche en fente et escaliers.',
|
||||
'Audio-guided session during a walk. Brisk walking, high knees, lunge walking and stairs.',
|
||||
['Cardio', 'Membres inferieurs', 'Endurance'],
|
||||
['Cardio', 'Lower Body', 'Endurance'],
|
||||
85,
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
// ─── Week 4: Autonomie — Decharge (3x mixed formats) ──────────
|
||||
|
||||
const week4: TabataWeek = {
|
||||
weekNumber: 4,
|
||||
title: 'Autonomie — Semaine de decharge',
|
||||
titleEn: 'Autonomy — Deload Week',
|
||||
description: 'Semaine 4 = decharge + prise d\'autonomie. Choisir selon la journee. L\'objectif : integrer le mouvement comme reflexe.',
|
||||
descriptionEn: 'Week 4 = deload + building autonomy. Choose based on your day. Goal: make movement a reflex.',
|
||||
focus: 'Autonomie, choix du format selon la journee, consolidation',
|
||||
focusEn: 'Autonomy, choosing format based on the day, consolidation',
|
||||
isDeload: true,
|
||||
sessions: [
|
||||
// W4-S1 — Format A (deload)
|
||||
formatASession(
|
||||
'bur-w4-s1', 4, 1,
|
||||
'Micro-pause autonome', 'Autonomous Micro-break',
|
||||
'Format A en autonomie. 10 minutes pour relancer la concentration.',
|
||||
'Autonomous Format A. 10 minutes to boost concentration.',
|
||||
['Transverse', 'Fessiers', 'Epaules'],
|
||||
['Transverse Abdominis', 'Glutes', 'Shoulders'],
|
||||
CONTRACTION_ABDO_ISO, SERRAGE_FESSIER,
|
||||
EXTENSION_JAMBE_ASSIS, ELEVATION_TALONS_ASSIS,
|
||||
WALL_SIT, ELEVATION_TALONS_DEBOUT,
|
||||
PULL_APART, POMPES_BUREAU,
|
||||
32,
|
||||
),
|
||||
// W4-S2 — Format B (deload)
|
||||
formatBSession(
|
||||
'bur-w4-s2', 4, 2,
|
||||
'Pause longue autonome', 'Autonomous Long Break',
|
||||
'Format B en autonomie. Mouvements debout avec proprioception et mobilite.',
|
||||
'Autonomous Format B. Standing movements with proprioception and mobility.',
|
||||
['Quadriceps', 'Equilibre', 'Mobilite thoracique'],
|
||||
['Quadriceps', 'Balance', 'Thoracic Mobility'],
|
||||
SQUAT_SILENCIEUX, ELEVATION_TALONS_DEBOUT,
|
||||
FENTE_STATIQUE_ROTATION, ISOMETRIE_FESSIERS_DEBOUT,
|
||||
PULL_APART, ROTATION_BUSTE_RAPIDE,
|
||||
WALL_SIT, CALF_RAISES_FERMES,
|
||||
BUREAU_DIPS, PULL_APART,
|
||||
80,
|
||||
),
|
||||
// W4-S3 — Format C (walking meeting deload)
|
||||
formatCSession(
|
||||
'bur-w4-s3', 4, 3,
|
||||
'Marche active autonome', 'Autonomous Active Walk',
|
||||
'Walking meeting en autonomie. Le mouvement est devenu un reflexe.',
|
||||
'Autonomous walking meeting. Movement has become a reflex.',
|
||||
['Cardio', 'Endurance', 'Bien-etre'],
|
||||
['Cardio', 'Endurance', 'Wellbeing'],
|
||||
75,
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
// ─── Program Export ─────────────────────────────────────────────
|
||||
|
||||
export const BUREAU_PROGRAM: TabataProgram = {
|
||||
id: 'bureau',
|
||||
title: 'Programme Bureau',
|
||||
titleEn: 'Office Program',
|
||||
description: 'Integrer le mouvement comme reflexe dans la journee de travail. 10 minutes de mouvement actif reduisent les douleurs lombaires de 30% et ameliorent la concentration de 20%.',
|
||||
descriptionEn: 'Make movement a reflex in your workday. 10 minutes of active movement reduces lower back pain by 30% and improves concentration by 20%.',
|
||||
tier: 'premium',
|
||||
accentColor: '#FFD60A',
|
||||
icon: 'briefcase',
|
||||
durationWeeks: 4,
|
||||
sessionsPerWeek: 4, // Variable: 3 in W1/W2/W4, 4 in W3
|
||||
totalSessions: 13,
|
||||
equipment: {
|
||||
required: [],
|
||||
optional: ['Chaise', 'Bureau stable', 'Mur'],
|
||||
},
|
||||
focusAreas: ['Wall sit', 'Gainage assis', 'Pompes bureau', 'Extension jambe assise', 'Proprioception'],
|
||||
focusAreasEn: ['Wall sit', 'Seated core', 'Desk push-ups', 'Seated leg extension', 'Proprioception'],
|
||||
principles: [
|
||||
'Silence : pas de sauts, pas d\'impacts, pas de chutes au sol',
|
||||
'Pas de sol : exercices debout ou assis sur une chaise uniquement',
|
||||
'Pas de sueur visible : intensite calibree pour le bureau',
|
||||
'10 ou 20 min max selon le format',
|
||||
'Tenue de bureau : aucun equipement sportif necessaire',
|
||||
],
|
||||
principlesEn: [
|
||||
'Silent: no jumps, no impact, no floor work',
|
||||
'No floor: standing or seated on a chair only',
|
||||
'No visible sweat: intensity calibrated for the office',
|
||||
'10 or 20 min max depending on format',
|
||||
'Office attire: no sports equipment needed',
|
||||
],
|
||||
completionCriteria: [
|
||||
'Realiser un Format B complet sans pause supplementaire',
|
||||
'Tenir un wall sit 90 secondes sans douleur',
|
||||
'Integrer au moins 3 pauses actives par semaine de travail',
|
||||
'Connaitre les exercices par cœur (sans regarder l\'ecran)',
|
||||
],
|
||||
completionCriteriaEn: [
|
||||
'Complete a full Format B without extra breaks',
|
||||
'Hold a wall sit for 90 seconds without pain',
|
||||
'Integrate at least 3 active breaks per work week',
|
||||
'Know the exercises by heart (without looking at the screen)',
|
||||
],
|
||||
nextProgramId: 'debutant',
|
||||
weeks: [week1, week2, week3, week4],
|
||||
}
|
||||
|
||||
/** All sessions flattened for quick lookup */
|
||||
export const BUREAU_SESSIONS: TabataSession[] = BUREAU_PROGRAM.weeks.flatMap(w => w.sessions)
|
||||
717
src/shared/data/tabata/debutant.ts
Normal file
717
src/shared/data/tabata/debutant.ts
Normal file
@@ -0,0 +1,717 @@
|
||||
/**
|
||||
* Programme Débutant — 4 semaines, 3 séances/semaine
|
||||
* Source: TabataKine_Guide_Complet.md
|
||||
* Tier: GRATUIT
|
||||
*
|
||||
* Règles:
|
||||
* - Zéro impact les 2 premières semaines
|
||||
* - La technique avant l'intensité
|
||||
* - Semaine 4 = décharge (volume réduit 40%)
|
||||
*/
|
||||
|
||||
import type { TabataProgram, TabataSession, TabataWeek, TabataBlock, TabataExercise } from '../../types/program'
|
||||
|
||||
// ─── Exercise helpers ──────────────────────────────────────────
|
||||
|
||||
const ex = (
|
||||
name: string, nameEn: string,
|
||||
conseil: string, conseilEn: string,
|
||||
opts?: { modification?: string; modificationEn?: string; progression?: string; progressionEn?: string }
|
||||
): TabataExercise => ({
|
||||
name, nameEn, conseil, conseilEn, ...opts,
|
||||
})
|
||||
|
||||
const block = (
|
||||
id: string,
|
||||
odd: TabataExercise,
|
||||
even: TabataExercise,
|
||||
rounds = 8,
|
||||
workTime = 20,
|
||||
restTime = 10,
|
||||
): TabataBlock => ({
|
||||
id, oddExercise: odd, evenExercise: even, rounds, workTime, restTime,
|
||||
})
|
||||
|
||||
// ─── Exercises Library ─────────────────────────────────────────
|
||||
|
||||
const SQUAT_CLASSIQUE = ex(
|
||||
'Squat classique', 'Classic Squat',
|
||||
'Si les talons se soulèvent : écarter davantage les pieds ou placer un support sous les talons. Genoux dans l\'axe des pieds, jamais vers l\'intérieur.',
|
||||
'If heels lift: widen your stance or place a support under heels. Keep knees aligned with feet, never cave inward.',
|
||||
)
|
||||
|
||||
const PONT_FESSIER = ex(
|
||||
'Pont fessier', 'Glute Bridge',
|
||||
'Ne pas creuser le bas du dos en position haute. Le bassin monte grâce aux fessiers, pas grâce aux lombaires.',
|
||||
'Don\'t arch your lower back at the top. Drive through your glutes, not your lower back.',
|
||||
)
|
||||
|
||||
const POMPE_GENOUX = ex(
|
||||
'Pompes genoux', 'Knee Push-ups',
|
||||
'Si douleur aux poignets : faire sur les poings fermés ou les avant-bras. Vérifier l\'alignement poignet / coude / épaule.',
|
||||
'If wrist pain: do them on closed fists or forearms. Check wrist/elbow/shoulder alignment.',
|
||||
)
|
||||
|
||||
const PLANCHE_AVANT_BRAS = ex(
|
||||
'Planche avant-bras', 'Forearm Plank',
|
||||
'Respirer normalement. La planche doit être une contraction active — réduire la durée si tremblements excessifs.',
|
||||
'Breathe normally. Plank should be an active contraction — reduce duration if excessive shaking.',
|
||||
)
|
||||
|
||||
const STEP_TOUCH = ex(
|
||||
'Step touch latéral', 'Lateral Step Touch',
|
||||
'Garder le regard droit. Les bras actifs contribuent à 20% de l\'effort cardiovasculaire.',
|
||||
'Keep eyes forward. Active arms contribute 20% of cardiovascular effort.',
|
||||
)
|
||||
|
||||
const SUPERMAN = ex(
|
||||
'Superman', 'Superman',
|
||||
'Ne pas forcer sur le cou — il reste dans l\'axe. Exercice roi pour les lombaires et les paravertébraux.',
|
||||
'Don\'t strain your neck — keep it aligned. Top exercise for lower back and paraspinal muscles.',
|
||||
)
|
||||
|
||||
const FENTE_AVANT = ex(
|
||||
'Fente avant alternée', 'Alternating Forward Lunge',
|
||||
'Le genou avant ne dépasse pas les orteils. Si douleur rotulienne : réduire l\'amplitude.',
|
||||
'Front knee should not pass toes. If kneecap pain: reduce range of motion.',
|
||||
)
|
||||
|
||||
const DEAD_BUG = ex(
|
||||
'Dead bug', 'Dead Bug',
|
||||
'Gainage profond transverse — excellent pour les lombaires. La version un seul membre reste disponible si besoin.',
|
||||
'Deep transverse core engagement — excellent for lower back. Single-limb version available if needed.',
|
||||
)
|
||||
|
||||
const BIRD_DOG = ex(
|
||||
'Bird dog', 'Bird Dog',
|
||||
'Placer une bouteille sur le dos pour vérifier que le bassin reste horizontal.',
|
||||
'Place a bottle on your back to check that your pelvis stays horizontal.',
|
||||
)
|
||||
|
||||
const SQUAT_JUMP_LOW = ex(
|
||||
'Squat jump low', 'Low Squat Jump',
|
||||
'Réception silencieuse sur avant-pied. Si bruit à l\'atterrissage : réduire la hauteur.',
|
||||
'Silent landing on forefoot. If you hear noise on landing: reduce height.',
|
||||
)
|
||||
|
||||
const STEP_UP = ex(
|
||||
'Step-up sur marche', 'Step-up',
|
||||
'Genou de la jambe de montée au-dessus du pied de la marche.',
|
||||
'Knee of stepping leg should be above the step foot.',
|
||||
)
|
||||
|
||||
const MOUNTAIN_CLIMBER_LENt = ex(
|
||||
'Mountain climber lent', 'Slow Mountain Climber',
|
||||
'Gainage parfait pendant tout le mouvement. Ne pas laisser les hanches monter.',
|
||||
'Perfect core engagement throughout. Don\'t let hips rise.',
|
||||
)
|
||||
|
||||
const PONT_FESSIER_UNILAT = ex(
|
||||
'Pont fessier unilatéral', 'Single-leg Glute Bridge',
|
||||
'Le bassin ne doit pas s\'incliner du côté de la jambe levée — signe de faiblesse du moyen fessier.',
|
||||
'Pelvis should not tilt toward the raised leg — indicates gluteus medius weakness.',
|
||||
)
|
||||
|
||||
const FENTE_AVANT_MAÎTRISÉE = ex(
|
||||
'Fente avant maîtrisée', 'Controlled Forward Lunge',
|
||||
'¼ de descente supplémentaire par rapport à la semaine 1. Contrôle total du mouvement.',
|
||||
'Quarter descent deeper than week 1. Total control of the movement.',
|
||||
)
|
||||
|
||||
const SQUAT_CONSOLIDATION = ex(
|
||||
'Squat classique (consolidation)', 'Classic Squat (consolidation)',
|
||||
'Augmenter la profondeur, viser cuisses parallèles au sol, tempo 2-1-2.',
|
||||
'Increase depth, aim for thighs parallel to floor, tempo 2-1-2.',
|
||||
)
|
||||
|
||||
const PONT_FESSIER_CONSOL = ex(
|
||||
'Pont fessier (consolidation)', 'Glute Bridge (consolidation)',
|
||||
'Ajouter 1 sec de maintien en haut, soulever les orteils pour intensifier.',
|
||||
'Add 1 sec hold at the top, lift toes to intensify.',
|
||||
)
|
||||
|
||||
const POMPE_GENOUX_CONSOL = ex(
|
||||
'Pompes genoux (consolidation)', 'Knee Push-ups (consolidation)',
|
||||
'Essayer 2–3 pompes complètes sur orteils si possible.',
|
||||
'Try 2–3 full push-ups on toes if possible.',
|
||||
)
|
||||
|
||||
const PLANCHE_CONSOL = ex(
|
||||
'Planche avant-bras (consolidation)', 'Forearm Plank (consolidation)',
|
||||
'Tenter la planche sur orteils 10 sec puis repasser sur genoux si besoin.',
|
||||
'Try plank on toes for 10 sec then return to knees if needed.',
|
||||
)
|
||||
|
||||
const SUPERMAN_DYNAMIQUE = ex(
|
||||
'Superman dynamique', 'Dynamic Superman',
|
||||
'Version dynamique : lever et descendre en rythme avec la respiration.',
|
||||
'Dynamic version: raise and lower in rhythm with breathing.',
|
||||
)
|
||||
|
||||
const STEP_TOUCH_CONSOL = ex(
|
||||
'Step touch (consolidation)', 'Step Touch (consolidation)',
|
||||
'Augmenter la vitesse, bras au-dessus des épaules.',
|
||||
'Increase speed, arms above shoulders.',
|
||||
)
|
||||
|
||||
const SUPERMAN_CONSOL = ex(
|
||||
'Superman (consolidation)', 'Superman (consolidation)',
|
||||
'Maintenir la position haute 3 secondes au lieu de 2.',
|
||||
'Hold the top position for 3 seconds instead of 2.',
|
||||
)
|
||||
|
||||
const FENTE_ROTATION = ex(
|
||||
'Fente avant + rotation de buste', 'Forward Lunge + Torso Rotation',
|
||||
'En fente basse, rotation du tronc vers le genou avant.',
|
||||
'In low lunge, rotate torso toward front knee.',
|
||||
)
|
||||
|
||||
const DEAD_BUG_LENt = ex(
|
||||
'Dead bug lent', 'Slow Dead Bug',
|
||||
'5 secondes par membre, qualité absolue.',
|
||||
'5 seconds per limb, absolute quality.',
|
||||
)
|
||||
|
||||
// ─── Week 1: Découverte du rythme (1 bloc/séance) ────────────
|
||||
|
||||
const week1: TabataWeek = {
|
||||
weekNumber: 1,
|
||||
title: 'Découverte du rythme',
|
||||
titleEn: 'Finding Your Rhythm',
|
||||
description: 'Un bloc tabata par séance (4 min) + échauffement + retour au calme. Durée totale : ~20 minutes.',
|
||||
descriptionEn: 'One tabata block per session (4 min) + warmup + cooldown. Total duration: ~20 min.',
|
||||
focus: 'Apprentissage du protocole 20/10, technique de base',
|
||||
focusEn: 'Learning the 20/10 protocol, basic technique',
|
||||
isDeload: false,
|
||||
sessions: [
|
||||
// 1A — Membres inférieurs
|
||||
{
|
||||
id: 'deb-w1-s1',
|
||||
week: 1, order: 1,
|
||||
title: 'Membres inférieurs',
|
||||
titleEn: 'Lower Body',
|
||||
description: 'Squat classique et pont fessier pour construire les bases des jambes.',
|
||||
descriptionEn: 'Classic squat and glute bridge to build leg foundations.',
|
||||
focus: ['Quadriceps', 'Fessiers', 'Mollets'],
|
||||
focusEn: ['Quadriceps', 'Glutes', 'Calves'],
|
||||
warmup: {
|
||||
movements: [
|
||||
{ name: 'Marche sur place genoux hauts', nameEn: 'High-knee March in Place', duration: 60 },
|
||||
{ name: 'Cercles de chevilles', nameEn: 'Ankle Circles', duration: 60 },
|
||||
{ name: 'Flexions de genoux lentes', nameEn: 'Slow Knee Bends', duration: 60 },
|
||||
{ name: 'Fentes statiques alternées', nameEn: 'Alternating Static Lunges', duration: 60 },
|
||||
],
|
||||
totalDuration: 240,
|
||||
},
|
||||
blocks: [
|
||||
block('deb-w1-s1-b1', SQUAT_CLASSIQUE, PONT_FESSIER),
|
||||
],
|
||||
cooldown: {
|
||||
movements: [
|
||||
{ name: 'Étirement quadriceps debout (chaque jambe)', nameEn: 'Standing Quad Stretch (each leg)', duration: 45 },
|
||||
{ name: 'Étirement ischio-jambiers assis (chaque jambe)', nameEn: 'Seated Hamstring Stretch (each leg)', duration: 45 },
|
||||
{ name: 'Respiration diaphragmatique', nameEn: 'Diaphragmatic Breathing', duration: 30 },
|
||||
],
|
||||
totalDuration: 120,
|
||||
},
|
||||
equipment: ['Tapis (optionnel)'],
|
||||
totalRounds: 8,
|
||||
totalDuration: 20,
|
||||
calories: 45,
|
||||
},
|
||||
// 1B — Membres supérieurs & gainage
|
||||
{
|
||||
id: 'deb-w1-s2',
|
||||
week: 1, order: 2,
|
||||
title: 'Membres supérieurs & gainage',
|
||||
titleEn: 'Upper Body & Core',
|
||||
description: 'Pompes sur genoux et planche avant-bras pour renforcer le haut du corps.',
|
||||
descriptionEn: 'Knee push-ups and forearm plank to strengthen upper body.',
|
||||
focus: ['Pectoraux', 'Triceps', 'Abdominaux profonds'],
|
||||
focusEn: ['Chest', 'Triceps', 'Deep Core'],
|
||||
warmup: {
|
||||
movements: [
|
||||
{ name: 'Rotations d\'épaules avant et arrière', nameEn: 'Shoulder Circles Forward & Back', duration: 60 },
|
||||
{ name: 'Ouvertures de poitrine', nameEn: 'Chest Openers', duration: 60 },
|
||||
{ name: 'Cercles de poignets', nameEn: 'Wrist Circles', duration: 60 },
|
||||
{ name: 'Cat-cow (mobilité lombaire)', nameEn: 'Cat-Cow (Spinal Mobility)', duration: 60 },
|
||||
],
|
||||
totalDuration: 240,
|
||||
},
|
||||
blocks: [
|
||||
block('deb-w1-s2-b1', POMPE_GENOUX, PLANCHE_AVANT_BRAS),
|
||||
],
|
||||
cooldown: {
|
||||
movements: [
|
||||
{ name: 'Étirement pectoraux contre un mur (chaque côté)', nameEn: 'Wall Chest Stretch (each side)', duration: 45 },
|
||||
{ name: 'Étirement triceps derrière la tête', nameEn: 'Overhead Triceps Stretch', duration: 30 },
|
||||
{ name: 'Étirement cervical latéral doux', nameEn: 'Gentle Lateral Neck Stretch', duration: 30 },
|
||||
],
|
||||
totalDuration: 120,
|
||||
},
|
||||
equipment: ['Tapis (optionnel)'],
|
||||
totalRounds: 8,
|
||||
totalDuration: 20,
|
||||
calories: 40,
|
||||
},
|
||||
// 1C — Corps entier
|
||||
{
|
||||
id: 'deb-w1-s3',
|
||||
week: 1, order: 3,
|
||||
title: 'Corps entier',
|
||||
titleEn: 'Full Body',
|
||||
description: 'Step touch latéral et superman pour une séance complète sans impact.',
|
||||
descriptionEn: 'Lateral step touch and superman for a complete no-impact session.',
|
||||
focus: ['Cardio léger', 'Dos', 'Épaules'],
|
||||
focusEn: ['Light Cardio', 'Back', 'Shoulders'],
|
||||
warmup: {
|
||||
movements: [
|
||||
{ name: 'Jumping jacks lents sans sauter', nameEn: 'Slow Jumping Jacks (no jump)', duration: 60 },
|
||||
{ name: 'Rotations de hanches debout', nameEn: 'Standing Hip Circles', duration: 60 },
|
||||
{ name: 'Marche avec bras croisés', nameEn: 'Walking with Crossed Arms', duration: 60 },
|
||||
{ name: 'Squats ¼ de descente', nameEn: 'Quarter Squats', duration: 60 },
|
||||
],
|
||||
totalDuration: 240,
|
||||
},
|
||||
blocks: [
|
||||
block('deb-w1-s3-b1', STEP_TOUCH, SUPERMAN),
|
||||
],
|
||||
cooldown: {
|
||||
movements: [
|
||||
{ name: 'Posture de l\'enfant (balasana)', nameEn: 'Child\'s Pose (Balasana)', duration: 60 },
|
||||
{ name: 'Étirement des hanches en pigeon (chaque côté)', nameEn: 'Pigeon Hip Stretch (each side)', duration: 45 },
|
||||
],
|
||||
totalDuration: 105,
|
||||
},
|
||||
equipment: ['Tapis (optionnel)'],
|
||||
totalRounds: 8,
|
||||
totalDuration: 20,
|
||||
calories: 42,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// ─── Week 2: Consolidation (2 blocs/séance) ──────────────────
|
||||
|
||||
const week2: TabataWeek = {
|
||||
weekNumber: 2,
|
||||
title: 'Consolidation',
|
||||
titleEn: 'Building Strength',
|
||||
description: '2 blocs tabata + 1 min récupération entre les blocs. Durée totale : ~25 minutes.',
|
||||
descriptionEn: '2 tabata blocks + 1 min recovery between blocks. Total duration: ~25 min.',
|
||||
focus: 'Consolidation des mouvements, introduction de nouveaux exercices',
|
||||
focusEn: 'Consolidating movements, introducing new exercises',
|
||||
isDeload: false,
|
||||
sessions: [
|
||||
// 2A — Membres inférieurs renforcés
|
||||
{
|
||||
id: 'deb-w2-s1',
|
||||
week: 2, order: 1,
|
||||
title: 'Membres inférieurs renforcés',
|
||||
titleEn: 'Strengthened Lower Body',
|
||||
description: 'Consolidation squat + pont fessier, puis fente avant + dead bug.',
|
||||
descriptionEn: 'Consolidation squat + glute bridge, then forward lunge + dead bug.',
|
||||
focus: ['Quadriceps', 'Fessiers', 'Transverse'],
|
||||
focusEn: ['Quadriceps', 'Glutes', 'Transverse Abdominis'],
|
||||
warmup: {
|
||||
movements: [
|
||||
{ name: 'Marche sur place avec genoux hauts', nameEn: 'High-knee March', duration: 60 },
|
||||
{ name: 'Squats lents d\'activation', nameEn: 'Slow Activation Squats', duration: 60 },
|
||||
{ name: 'Cercles de hanches', nameEn: 'Hip Circles', duration: 60 },
|
||||
{ name: 'Fentes statiques légères', nameEn: 'Light Static Lunges', duration: 60 },
|
||||
],
|
||||
totalDuration: 240,
|
||||
},
|
||||
blocks: [
|
||||
block('deb-w2-s1-b1', SQUAT_CONSOLIDATION, PONT_FESSIER_CONSOL),
|
||||
block('deb-w2-s1-b2', FENTE_AVANT, DEAD_BUG),
|
||||
],
|
||||
cooldown: {
|
||||
movements: [
|
||||
{ name: 'Étirement du psoas en fente basse (chaque côté)', nameEn: 'Low Lunge Psoas Stretch (each side)', duration: 60 },
|
||||
{ name: 'Étirement des ischio-jambiers allongé', nameEn: 'Lying Hamstring Stretch', duration: 45 },
|
||||
{ name: 'Respiration guidée', nameEn: 'Guided Breathing', duration: 30 },
|
||||
],
|
||||
totalDuration: 135,
|
||||
},
|
||||
equipment: ['Tapis (optionnel)'],
|
||||
totalRounds: 16,
|
||||
totalDuration: 25,
|
||||
calories: 75,
|
||||
},
|
||||
// 2B — Haut du corps renforcé
|
||||
{
|
||||
id: 'deb-w2-s2',
|
||||
week: 2, order: 2,
|
||||
title: 'Haut du corps renforcé',
|
||||
titleEn: 'Strengthened Upper Body',
|
||||
description: 'Consolidation pompes + planche, puis bird dog + superman dynamique.',
|
||||
descriptionEn: 'Consolidation push-ups + plank, then bird dog + dynamic superman.',
|
||||
focus: ['Pectoraux', 'Épaules', 'Lombaires'],
|
||||
focusEn: ['Chest', 'Shoulders', 'Lower Back'],
|
||||
warmup: {
|
||||
movements: [
|
||||
{ name: 'Rotations d\'épaules complètes', nameEn: 'Full Shoulder Rotations', duration: 60 },
|
||||
{ name: 'Pompes lentes d\'activation', nameEn: 'Slow Activation Push-ups', duration: 60 },
|
||||
{ name: 'Planche dynamique haute ↔ basse', nameEn: 'Dynamic Plank High ↔ Low', duration: 60 },
|
||||
{ name: 'Cat-cow', nameEn: 'Cat-Cow', duration: 60 },
|
||||
],
|
||||
totalDuration: 240,
|
||||
},
|
||||
blocks: [
|
||||
block('deb-w2-s2-b1', POMPE_GENOUX_CONSOL, PLANCHE_CONSOL),
|
||||
block('deb-w2-s2-b2', BIRD_DOG, SUPERMAN_DYNAMIQUE),
|
||||
],
|
||||
cooldown: {
|
||||
movements: [
|
||||
{ name: 'Étirement pectoraux en porte', nameEn: 'Doorway Chest Stretch', duration: 45 },
|
||||
{ name: 'Étirement grand dorsal', nameEn: 'Lat Stretch', duration: 45 },
|
||||
{ name: 'Respiration guidée', nameEn: 'Guided Breathing', duration: 30 },
|
||||
],
|
||||
totalDuration: 120,
|
||||
},
|
||||
equipment: ['Tapis (optionnel)'],
|
||||
totalRounds: 16,
|
||||
totalDuration: 25,
|
||||
calories: 70,
|
||||
},
|
||||
// 2C — Corps entier mixte
|
||||
{
|
||||
id: 'deb-w2-s3',
|
||||
week: 2, order: 3,
|
||||
title: 'Corps entier mixte',
|
||||
titleEn: 'Mixed Full Body',
|
||||
description: 'Consolidation step touch + superman, puis fente rotation + dead bug lent.',
|
||||
descriptionEn: 'Consolidation step touch + superman, then lunge rotation + slow dead bug.',
|
||||
focus: ['Cardio', 'Gainage', 'Mobilité thoracique'],
|
||||
focusEn: ['Cardio', 'Core', 'Thoracic Mobility'],
|
||||
warmup: {
|
||||
movements: [
|
||||
{ name: 'Jumping jacks lents', nameEn: 'Slow Jumping Jacks', duration: 60 },
|
||||
{ name: 'Rotations de hanches', nameEn: 'Hip Circles', duration: 60 },
|
||||
{ name: 'Squats légers', nameEn: 'Light Squats', duration: 60 },
|
||||
{ name: 'Mobilisation cervicale', nameEn: 'Neck Mobilization', duration: 60 },
|
||||
],
|
||||
totalDuration: 240,
|
||||
},
|
||||
blocks: [
|
||||
block('deb-w2-s3-b1', STEP_TOUCH_CONSOL, SUPERMAN_CONSOL),
|
||||
block('deb-w2-s3-b2', FENTE_ROTATION, DEAD_BUG_LENt),
|
||||
],
|
||||
cooldown: {
|
||||
movements: [
|
||||
{ name: 'Posture de l\'enfant', nameEn: 'Child\'s Pose', duration: 60 },
|
||||
{ name: 'Torsion vertébrale au sol (chaque côté)', nameEn: 'Supine Spinal Twist (each side)', duration: 45 },
|
||||
{ name: 'Respiration 4-7-8', nameEn: '4-7-8 Breathing', duration: 30 },
|
||||
],
|
||||
totalDuration: 135,
|
||||
},
|
||||
equipment: ['Tapis (optionnel)'],
|
||||
totalRounds: 16,
|
||||
totalDuration: 25,
|
||||
calories: 72,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// ─── Week 3: Montée en intensité (3 blocs/séance) ────────────
|
||||
|
||||
const week3: TabataWeek = {
|
||||
weekNumber: 3,
|
||||
title: 'Montée en intensité',
|
||||
titleEn: 'Building Intensity',
|
||||
description: '3 blocs tabata + 1 min récupération entre chaque. Durée totale : ~30 minutes.',
|
||||
descriptionEn: '3 tabata blocks + 1 min recovery between each. Total duration: ~30 min.',
|
||||
focus: 'Introduction d\'impacts très légers, augmentation du volume',
|
||||
focusEn: 'Introducing very light impact, increasing volume',
|
||||
isDeload: false,
|
||||
sessions: [
|
||||
// 3A — Membres inférieurs
|
||||
{
|
||||
id: 'deb-w3-s1',
|
||||
week: 3, order: 1,
|
||||
title: 'Membres inférieurs explosifs',
|
||||
titleEn: 'Explosive Lower Body',
|
||||
description: 'Squat classique + pont unilatéral, squat jump low + fente maîtrisée, step-up + mountain climber.',
|
||||
descriptionEn: 'Classic squat + single-leg bridge, low squat jump + controlled lunge, step-up + mountain climber.',
|
||||
focus: ['Puissance', 'Équilibre', 'Proprioception'],
|
||||
focusEn: ['Power', 'Balance', 'Proprioception'],
|
||||
warmup: {
|
||||
movements: [
|
||||
{ name: 'Marche rapide avec bras actifs', nameEn: 'Brisk Walk with Active Arms', duration: 60 },
|
||||
{ name: 'Squats lents x10', nameEn: 'Slow Squats x10', duration: 60 },
|
||||
{ name: 'Montées de genoux', nameEn: 'High Knees', duration: 60 },
|
||||
{ name: 'Hip circles', nameEn: 'Hip Circles', duration: 60 },
|
||||
],
|
||||
totalDuration: 240,
|
||||
},
|
||||
blocks: [
|
||||
block('deb-w3-s1-b1', SQUAT_CONSOLIDATION, PONT_FESSIER_UNILAT),
|
||||
block('deb-w3-s1-b2', SQUAT_JUMP_LOW, FENTE_AVANT_MAÎTRISÉE),
|
||||
block('deb-w3-s1-b3', STEP_UP, MOUNTAIN_CLIMBER_LENt),
|
||||
],
|
||||
cooldown: {
|
||||
movements: [
|
||||
{ name: 'Étirement psoas en fente basse', nameEn: 'Low Lunge Psoas Stretch', duration: 60 },
|
||||
{ name: 'Étirement mollets contre un mur', nameEn: 'Wall Calf Stretch', duration: 45 },
|
||||
{ name: 'Automassage quadriceps', nameEn: 'Self-massage Quadriceps', duration: 45 },
|
||||
{ name: 'Respiration guidée', nameEn: 'Guided Breathing', duration: 30 },
|
||||
],
|
||||
totalDuration: 180,
|
||||
},
|
||||
equipment: ['Marche ou step bas', 'Tapis (optionnel)'],
|
||||
totalRounds: 24,
|
||||
totalDuration: 30,
|
||||
calories: 120,
|
||||
},
|
||||
// 3B — Haut du corps
|
||||
{
|
||||
id: 'deb-w3-s2',
|
||||
week: 3, order: 2,
|
||||
title: 'Haut du corps dynamique',
|
||||
titleEn: 'Dynamic Upper Body',
|
||||
description: 'Pompes + planche consolidation, pompes rotation + planche tap épaule, superman dynamique.',
|
||||
descriptionEn: 'Push-ups + plank consolidation, rotation push-ups + shoulder tap plank, dynamic superman.',
|
||||
focus: ['Pectoraux', 'Épaules', 'Stabilité scapulaire'],
|
||||
focusEn: ['Chest', 'Shoulders', 'Scapular Stability'],
|
||||
warmup: {
|
||||
movements: [
|
||||
{ name: 'Rotations complètes d\'épaules', nameEn: 'Full Shoulder Rotations', duration: 60 },
|
||||
{ name: 'Pompes lentes x8', nameEn: 'Slow Push-ups x8', duration: 60 },
|
||||
{ name: 'Planche dynamique haute ↔ basse', nameEn: 'Dynamic Plank High ↔ Low', duration: 60 },
|
||||
{ name: 'Dead bug lent', nameEn: 'Slow Dead Bug', duration: 60 },
|
||||
],
|
||||
totalDuration: 240,
|
||||
},
|
||||
blocks: [
|
||||
block('deb-w3-s2-b1', POMPE_GENOUX_CONSOL, PLANCHE_CONSOL),
|
||||
block('deb-w3-s2-b2', BIRD_DOG, SUPERMAN_DYNAMIQUE),
|
||||
block('deb-w3-s2-b3',
|
||||
ex('Pompe avec rotation', 'Push-up with Rotation', 'Ouvrir complètement la hanche à la rotation.', 'Fully open hip on rotation.'),
|
||||
ex('Planche tap épaule', 'Plank Shoulder Tap', 'Résister à la rotation du bassin. Pieds plus écartés pour stabiliser.', 'Resist pelvic rotation. Wider feet for stability.'),
|
||||
),
|
||||
],
|
||||
cooldown: {
|
||||
movements: [
|
||||
{ name: 'Étirement pectoraux en porte', nameEn: 'Doorway Chest Stretch', duration: 45 },
|
||||
{ name: 'Étirement grand dorsal', nameEn: 'Lat Stretch', duration: 45 },
|
||||
{ name: 'Mobilisation cervicale douce', nameEn: 'Gentle Neck Mobilization', duration: 45 },
|
||||
],
|
||||
totalDuration: 135,
|
||||
},
|
||||
equipment: ['Tapis (optionnel)'],
|
||||
totalRounds: 24,
|
||||
totalDuration: 30,
|
||||
calories: 110,
|
||||
},
|
||||
// 3C — Corps entier
|
||||
{
|
||||
id: 'deb-w3-s3',
|
||||
week: 3, order: 3,
|
||||
title: 'Corps entier haute intensité',
|
||||
titleEn: 'High Intensity Full Body',
|
||||
description: 'Step touch + superman, fente rotation + dead bug, squat jump low + mountain climber.',
|
||||
descriptionEn: 'Step touch + superman, lunge rotation + dead bug, low squat jump + mountain climber.',
|
||||
focus: ['Cardio', 'Puissance', 'Endurance musculaire'],
|
||||
focusEn: ['Cardio', 'Power', 'Muscular Endurance'],
|
||||
warmup: {
|
||||
movements: [
|
||||
{ name: 'Marche rapide bras actifs', nameEn: 'Brisk Walk with Active Arms', duration: 60 },
|
||||
{ name: 'Squats d\'activation', nameEn: 'Activation Squats', duration: 60 },
|
||||
{ name: 'Fentes alternées lentes', nameEn: 'Slow Alternating Lunges', duration: 60 },
|
||||
{ name: 'Sauts légers sur place', nameEn: 'Light Jumps in Place', duration: 60 },
|
||||
],
|
||||
totalDuration: 240,
|
||||
},
|
||||
blocks: [
|
||||
block('deb-w3-s3-b1', STEP_TOUCH_CONSOL, SUPERMAN_CONSOL),
|
||||
block('deb-w3-s3-b2', FENTE_ROTATION, DEAD_BUG_LENt),
|
||||
block('deb-w3-s3-b3', SQUAT_JUMP_LOW, MOUNTAIN_CLIMBER_LENt),
|
||||
],
|
||||
cooldown: {
|
||||
movements: [
|
||||
{ name: 'Posture de l\'enfant', nameEn: 'Child\'s Pose', duration: 60 },
|
||||
{ name: 'Pigeon yoga (chaque côté)', nameEn: 'Pigeon Pose (each side)', duration: 45 },
|
||||
{ name: 'Respiration 4-7-8 × 4 cycles', nameEn: '4-7-8 Breathing × 4 cycles', duration: 60 },
|
||||
],
|
||||
totalDuration: 165,
|
||||
},
|
||||
equipment: ['Tapis (optionnel)'],
|
||||
totalRounds: 24,
|
||||
totalDuration: 30,
|
||||
calories: 115,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// ─── Week 4: Décharge & consolidation (2 blocs/séance) ──────
|
||||
|
||||
const week4: TabataWeek = {
|
||||
weekNumber: 4,
|
||||
title: 'Décharge & consolidation',
|
||||
titleEn: 'Deload & Consolidation',
|
||||
description: 'Retour à 2 blocs tabata. Volume réduit de 40%. C\'est pendant le repos que le corps consolide les adaptations.',
|
||||
descriptionEn: 'Back to 2 tabata blocks. Volume reduced by 40%. The body consolidates adaptations during rest.',
|
||||
focus: 'Technique parfaite, respiration consciente, ressenti musculaire',
|
||||
focusEn: 'Perfect technique, conscious breathing, muscle awareness',
|
||||
isDeload: true,
|
||||
sessions: [
|
||||
// 4A — Membres inférieurs (décharge)
|
||||
{
|
||||
id: 'deb-w4-s1',
|
||||
week: 4, order: 1,
|
||||
title: 'Membres inférieurs (décharge)',
|
||||
titleEn: 'Lower Body (Deload)',
|
||||
description: 'Focus sur la technique parfaite. Respiration coordonnée avec le mouvement.',
|
||||
descriptionEn: 'Focus on perfect technique. Breathing coordinated with movement.',
|
||||
focus: ['Technique', 'Respiration', 'Ressenti musculaire'],
|
||||
focusEn: ['Technique', 'Breathing', 'Muscle Awareness'],
|
||||
warmup: {
|
||||
movements: [
|
||||
{ name: 'Marche sur place', nameEn: 'March in Place', duration: 60 },
|
||||
{ name: 'Cercles de chevilles et hanches', nameEn: 'Ankle & Hip Circles', duration: 60 },
|
||||
{ name: 'Squats lents conscience corporelle', nameEn: 'Slow Mindful Squats', duration: 60 },
|
||||
{ name: 'Respiration diaphragmatique', nameEn: 'Diaphragmatic Breathing', duration: 60 },
|
||||
],
|
||||
totalDuration: 240,
|
||||
},
|
||||
blocks: [
|
||||
block('deb-w4-s1-b1', SQUAT_CONSOLIDATION, PONT_FESSIER_CONSOL),
|
||||
block('deb-w4-s1-b2', FENTE_AVANT, DEAD_BUG),
|
||||
],
|
||||
cooldown: {
|
||||
movements: [
|
||||
{ name: 'Étirement quadriceps debout', nameEn: 'Standing Quad Stretch', duration: 45 },
|
||||
{ name: 'Étirement ischio-jambiers', nameEn: 'Hamstring Stretch', duration: 45 },
|
||||
{ name: 'Respiration guidée', nameEn: 'Guided Breathing', duration: 30 },
|
||||
],
|
||||
totalDuration: 120,
|
||||
},
|
||||
equipment: ['Tapis (optionnel)'],
|
||||
totalRounds: 16,
|
||||
totalDuration: 25,
|
||||
calories: 70,
|
||||
},
|
||||
// 4B — Haut du corps (décharge)
|
||||
{
|
||||
id: 'deb-w4-s2',
|
||||
week: 4, order: 2,
|
||||
title: 'Haut du corps (décharge)',
|
||||
titleEn: 'Upper Body (Deload)',
|
||||
description: 'Qualité sur chaque répétition. Bilan personnel sur les exercices maîtrisés.',
|
||||
descriptionEn: 'Quality on every rep. Personal assessment of mastered exercises.',
|
||||
focus: ['Technique', 'Gainage profond', 'Contrôle'],
|
||||
focusEn: ['Technique', 'Deep Core', 'Control'],
|
||||
warmup: {
|
||||
movements: [
|
||||
{ name: 'Rotations d\'épaules', nameEn: 'Shoulder Rotations', duration: 60 },
|
||||
{ name: 'Ouvertures de poitrine', nameEn: 'Chest Openers', duration: 60 },
|
||||
{ name: 'Cat-cow lent', nameEn: 'Slow Cat-Cow', duration: 60 },
|
||||
{ name: 'Respiration diaphragmatique', nameEn: 'Diaphragmatic Breathing', duration: 60 },
|
||||
],
|
||||
totalDuration: 240,
|
||||
},
|
||||
blocks: [
|
||||
block('deb-w4-s2-b1', POMPE_GENOUX_CONSOL, PLANCHE_CONSOL),
|
||||
block('deb-w4-s2-b2', BIRD_DOG, SUPERMAN),
|
||||
],
|
||||
cooldown: {
|
||||
movements: [
|
||||
{ name: 'Étirement pectoraux', nameEn: 'Chest Stretch', duration: 45 },
|
||||
{ name: 'Étirement triceps', nameEn: 'Triceps Stretch', duration: 30 },
|
||||
{ name: 'Étirement cervical', nameEn: 'Neck Stretch', duration: 30 },
|
||||
],
|
||||
totalDuration: 105,
|
||||
},
|
||||
equipment: ['Tatis (optionnel)'],
|
||||
totalRounds: 16,
|
||||
totalDuration: 25,
|
||||
calories: 65,
|
||||
},
|
||||
// 4C — Corps entier bilan
|
||||
{
|
||||
id: 'deb-w4-s3',
|
||||
week: 4, order: 3,
|
||||
title: 'Corps entier — Bilan final',
|
||||
titleEn: 'Full Body — Final Assessment',
|
||||
description: 'Dernière séance du programme. Focus total sur la qualité. Quels exercices sont devenus faciles ?',
|
||||
descriptionEn: 'Final session of the program. Total focus on quality. Which exercises have become easy?',
|
||||
focus: ['Bilan global', 'Autonomie', 'Confiance'],
|
||||
focusEn: ['Overall Assessment', 'Autonomy', 'Confidence'],
|
||||
warmup: {
|
||||
movements: [
|
||||
{ name: 'Marche active', nameEn: 'Active Walk', duration: 60 },
|
||||
{ name: 'Mobilisation articulaire complète', nameEn: 'Full Joint Mobilization', duration: 60 },
|
||||
{ name: 'Squats lents avec respiration', nameEn: 'Slow Squats with Breathing', duration: 60 },
|
||||
{ name: 'Gainage léger', nameEn: 'Light Core Activation', duration: 60 },
|
||||
],
|
||||
totalDuration: 240,
|
||||
},
|
||||
blocks: [
|
||||
block('deb-w4-s3-b1', SQUAT_CLASSIQUE, STEP_TOUCH),
|
||||
block('deb-w4-s3-b2', SUPERMAN, DEAD_BUG),
|
||||
],
|
||||
cooldown: {
|
||||
movements: [
|
||||
{ name: 'Posture de l\'enfant', nameEn: 'Child\'s Pose', duration: 60 },
|
||||
{ name: 'Torsion vertébrale (chaque côté)', nameEn: 'Spinal Twist (each side)', duration: 45 },
|
||||
{ name: 'Savasana + respiration guidée', nameEn: 'Savasana + Guided Breathing', duration: 60 },
|
||||
],
|
||||
totalDuration: 165,
|
||||
},
|
||||
equipment: ['Tatis (optionnel)'],
|
||||
totalRounds: 16,
|
||||
totalDuration: 25,
|
||||
calories: 68,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// ─── Program Export ─────────────────────────────────────────────
|
||||
|
||||
export const DEBUTANT_PROGRAM: TabataProgram = {
|
||||
id: 'debutant',
|
||||
title: 'Débutant',
|
||||
titleEn: 'Beginner',
|
||||
description: 'Apprendre le protocole tabata, construire les bases techniques de chaque mouvement fondamental, et terminer 12 séances sans douleur articulaire.',
|
||||
descriptionEn: 'Learn the tabata protocol, build technical foundations for each fundamental movement, and complete 12 sessions without joint pain.',
|
||||
tier: 'free',
|
||||
accentColor: '#00C896', // Energy green
|
||||
icon: 'seedling',
|
||||
durationWeeks: 4,
|
||||
sessionsPerWeek: 3,
|
||||
totalSessions: 12,
|
||||
equipment: {
|
||||
required: [],
|
||||
optional: ['Tapis de yoga'],
|
||||
},
|
||||
focusAreas: ['Squat', 'Gainage', 'Pompes', 'Équilibre', 'Mobilité'],
|
||||
focusAreasEn: ['Squat', 'Core', 'Push-ups', 'Balance', 'Mobility'],
|
||||
principles: [
|
||||
'Zéro impact les 2 premières semaines',
|
||||
'La technique avant l\'intensité',
|
||||
'Semaine 4 = décharge (volume réduit 40%)',
|
||||
],
|
||||
principlesEn: [
|
||||
'Zero impact for the first 2 weeks',
|
||||
'Technique before intensity',
|
||||
'Week 4 = deload (40% volume reduction)',
|
||||
],
|
||||
completionCriteria: [
|
||||
'Planche sur avant-bras tenue 30 secondes',
|
||||
'10 squats propres consécutifs sans douleur',
|
||||
'5 pompes complètes corps aligné',
|
||||
'Aucune douleur articulaire résiduelle',
|
||||
],
|
||||
completionCriteriaEn: [
|
||||
'Hold forearm plank for 30 seconds',
|
||||
'10 clean consecutive squats without pain',
|
||||
'5 full push-ups with aligned body',
|
||||
'No residual joint pain after sessions',
|
||||
],
|
||||
nextProgramId: 'intermediaire',
|
||||
weeks: [week1, week2, week3, week4],
|
||||
}
|
||||
|
||||
/** All sessions flattened for quick lookup */
|
||||
export const DEBUTANT_SESSIONS: TabataSession[] = DEBUTANT_PROGRAM.weeks.flatMap(w => w.sessions)
|
||||
126
src/shared/data/tabata/index.ts
Normal file
126
src/shared/data/tabata/index.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Tabata Programs — Data Access Layer
|
||||
* Central exports and helper functions for tabata programs
|
||||
*/
|
||||
|
||||
import type { TabataProgram, TabataProgramId, TabataSession } from '../../types/program'
|
||||
import type { Workout, Exercise } from '../../types/workout'
|
||||
import { DEBUTANT_PROGRAM, DEBUTANT_SESSIONS } from './debutant'
|
||||
import { INTERMEDIAIRE_PROGRAM, INTERMEDIAIRE_SESSIONS } from './intermediaire'
|
||||
import { AVANCE_PROGRAM, AVANCE_SESSIONS } from './avance'
|
||||
import { BUREAU_PROGRAM, BUREAU_SESSIONS } from './bureau'
|
||||
|
||||
// ─── Program Registry ──────────────────────────────────────────
|
||||
|
||||
const _PROGRAMS: Record<TabataProgramId, TabataProgram> = {
|
||||
debutant: DEBUTANT_PROGRAM,
|
||||
intermediaire: INTERMEDIAIRE_PROGRAM,
|
||||
avance: AVANCE_PROGRAM,
|
||||
bureau: BUREAU_PROGRAM,
|
||||
}
|
||||
|
||||
// ─── Program Access ────────────────────────────────────────────
|
||||
|
||||
export function getTabataProgramById(id: TabataProgramId): TabataProgram | undefined {
|
||||
return _PROGRAMS[id]
|
||||
}
|
||||
|
||||
export function getAllTabataPrograms(): TabataProgram[] {
|
||||
return Object.values(_PROGRAMS).filter(Boolean) as TabataProgram[]
|
||||
}
|
||||
|
||||
export function getPremiumPrograms(): TabataProgram[] {
|
||||
return getAllTabataPrograms().filter(p => p.tier === 'premium')
|
||||
}
|
||||
|
||||
// ─── Session Access ────────────────────────────────────────────
|
||||
|
||||
/** Get all sessions across all programs */
|
||||
export function getAllTabataSessions(): TabataSession[] {
|
||||
return getAllTabataPrograms().flatMap(p => p.weeks.flatMap(w => w.sessions))
|
||||
}
|
||||
|
||||
/** Find a session by ID across all programs */
|
||||
export function getTabataSessionById(id: string): TabataSession | undefined {
|
||||
return getAllTabataSessions().find(s => s.id === id)
|
||||
}
|
||||
|
||||
/** Get all sessions for a specific program */
|
||||
export function getTabataSessionsByProgram(programId: TabataProgramId): TabataSession[] {
|
||||
const program = getTabataProgramById(programId)
|
||||
if (!program) return []
|
||||
return program.weeks.flatMap(w => w.sessions)
|
||||
}
|
||||
|
||||
/** Get sessions for a specific week of a program */
|
||||
export function getTabataSessionsByWeek(programId: TabataProgramId, weekNumber: number): TabataSession[] {
|
||||
const program = getTabataProgramById(programId)
|
||||
if (!program) return []
|
||||
const week = program.weeks.find(w => w.weekNumber === weekNumber)
|
||||
return week?.sessions ?? []
|
||||
}
|
||||
|
||||
/** Determine which program a session belongs to */
|
||||
export function getSessionProgramId(sessionId: string): TabataProgramId | undefined {
|
||||
for (const program of getAllTabataPrograms()) {
|
||||
const found = program.weeks.flatMap(w => w.sessions).find(s => s.id === sessionId)
|
||||
if (found) return program.id
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Check if a session ID belongs to the tabata system */
|
||||
export function isTabataSession(sessionId: string): boolean {
|
||||
return sessionId.startsWith('deb-') || sessionId.startsWith('int-') ||
|
||||
sessionId.startsWith('avc-') || sessionId.startsWith('bur-')
|
||||
}
|
||||
|
||||
// ─── Adapter: TabataSession → Workout ─────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a TabataSession's current block to a Workout-compatible object
|
||||
* for use with the existing player and timer system.
|
||||
*
|
||||
* For multi-block sessions, pass blockIndex (default: 0).
|
||||
* The player will need to handle block progression separately.
|
||||
*/
|
||||
export function tabataSessionToWorkoutAdapter(
|
||||
session: TabataSession,
|
||||
blockIndex = 0,
|
||||
): Workout {
|
||||
const block = session.blocks[blockIndex]
|
||||
if (!block) throw new Error(`Block ${blockIndex} not found in session ${session.id}`)
|
||||
|
||||
const exercises: Exercise[] = []
|
||||
for (let i = 0; i < block.rounds; i++) {
|
||||
const exercise = i % 2 === 0 ? block.oddExercise : block.evenExercise
|
||||
exercises.push({
|
||||
name: exercise.name,
|
||||
duration: block.workTime,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
id: session.id,
|
||||
title: `${session.title} — Bloc ${blockIndex + 1}`,
|
||||
trainerId: 'tabata',
|
||||
category: 'full-body',
|
||||
level: 'Beginner',
|
||||
duration: Math.ceil(session.totalDuration / session.blocks.length) as Workout['duration'],
|
||||
calories: Math.ceil(session.calories / session.blocks.length),
|
||||
exercises,
|
||||
rounds: block.rounds,
|
||||
prepTime: blockIndex === 0 ? 10 : 60,
|
||||
workTime: block.workTime,
|
||||
restTime: block.restTime,
|
||||
equipment: session.equipment,
|
||||
musicVibe: 'chill',
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Re-exports ────────────────────────────────────────────────
|
||||
|
||||
export { DEBUTANT_PROGRAM, DEBUTANT_SESSIONS } from './debutant'
|
||||
export { INTERMEDIAIRE_PROGRAM, INTERMEDIAIRE_SESSIONS } from './intermediaire'
|
||||
export { AVANCE_PROGRAM, AVANCE_SESSIONS } from './avance'
|
||||
export { BUREAU_PROGRAM, BUREAU_SESSIONS } from './bureau'
|
||||
609
src/shared/data/tabata/intermediaire.ts
Normal file
609
src/shared/data/tabata/intermediaire.ts
Normal file
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* Programme Intermédiaire — 4 semaines, 4 séances/semaine
|
||||
* Source: TabataKine_Guide_Complet.md — Section 3
|
||||
* Tier: PREMIUM
|
||||
*/
|
||||
|
||||
import type { TabataProgram, TabataSession, TabataWeek, TabataBlock, TabataExercise } from '../../types/program'
|
||||
|
||||
const ex = (
|
||||
name: string, nameEn: string,
|
||||
conseil: string, conseilEn: string,
|
||||
opts?: { modification?: string; modificationEn?: string; progression?: string; progressionEn?: string }
|
||||
): TabataExercise => ({ name, nameEn, conseil, conseilEn, ...opts })
|
||||
|
||||
const block = (
|
||||
id: string, odd: TabataExercise, even: TabataExercise,
|
||||
rounds = 8, workTime = 20, restTime = 10,
|
||||
): TabataBlock => ({ id, oddExercise: odd, evenExercise: even, rounds, workTime, restTime })
|
||||
|
||||
// ─── Exercises ─────────────────────────────────────────────────
|
||||
|
||||
const SQUAT_JUMP = ex(
|
||||
'Squat jump', 'Squat Jump',
|
||||
'Descente contrôlée, explosion vers le haut, réception silencieuse avant-pied. Si la réception fait du bruit : réduire la hauteur.',
|
||||
'Controlled descent, explode upward, silent forefoot landing. If landing makes noise: reduce height.',
|
||||
)
|
||||
|
||||
const PONT_UNILAT = ex(
|
||||
'Pont fessier unilatéral', 'Single-leg Glute Bridge',
|
||||
'Le bassin ne s\'inclined pas du côté de la jambe levée — signe de faiblesse du moyen fessier.',
|
||||
'Pelvis should not tilt toward raised leg — indicates gluteus medius weakness.',
|
||||
)
|
||||
|
||||
const FENTE_SAUTEE = ex(
|
||||
'Fente sautée alternée', 'Alternating Jump Lunge',
|
||||
'Si douleur antérieure du genou : revenir à la fente marchée. Réception absorbée sur 2–3 secondes.',
|
||||
'If anterior knee pain: return to walking lunge. Absorb landing over 2–3 seconds.',
|
||||
)
|
||||
|
||||
const ISO_SQUAT = ex(
|
||||
'Isométrie squat (chaise)', 'Wall Sit',
|
||||
'L\'isométrie renforce l\'endurance musculaire sans impact. Excellent pour skieurs et cyclistes.',
|
||||
'Isometrics build muscular endurance without impact. Great for skiers and cyclists.',
|
||||
)
|
||||
|
||||
const STEP_EXPLOSIF = ex(
|
||||
'Step-up explosif', 'Explosive Step-up',
|
||||
'La descente est aussi importante que la montée. Ne pas sauter en bas.',
|
||||
'The descent is as important as the ascent. Don\'t jump down.',
|
||||
)
|
||||
|
||||
const GLUTE_KICKBACK = ex(
|
||||
'Glute kickback à quatre pattes', 'Quadruped Glute Kickback',
|
||||
'Ne pas cambrer le bas du dos pour aller plus haut — c\'est la hanche qui travaille, pas les lombaires.',
|
||||
'Don\'t arch your lower back to go higher — hip does the work, not the lumbar spine.',
|
||||
)
|
||||
|
||||
const POMPE_CLASSIQUE = ex(
|
||||
'Pompes classiques complètes', 'Full Push-ups',
|
||||
'Corps aligné, coudes à 45°, descendre jusqu\'au contact de la poitrine.',
|
||||
'Body aligned, elbows at 45°, lower until chest contacts floor.',
|
||||
)
|
||||
|
||||
const RENEGADE_ROW = ex(
|
||||
'Renegade row sans poids', 'Weightless Renegade Row',
|
||||
'Gainage total pendant le mouvement. Le bassin ne se balance pas.',
|
||||
'Full core engagement throughout. Pelvis doesn\'t sway.',
|
||||
)
|
||||
|
||||
const POMPE_ROTATION_T = ex(
|
||||
'Pompe avec rotation en T', 'T-push-up',
|
||||
'Exercice combiné : obliques + dentatés antérieurs + rotateurs d\'épaule. Ouvrir complètement la hanche.',
|
||||
'Combined exercise: obliques + serratus anterior + shoulder rotators. Fully open the hip.',
|
||||
)
|
||||
|
||||
const PLANCHE_TAP = ex(
|
||||
'Planche tap épaule', 'Plank Shoulder Tap',
|
||||
'La résistance à la rotation du bassin est l\'objectif. Pieds plus écartés pour stabiliser.',
|
||||
'Resisting pelvic rotation is the goal. Wider feet for stability.',
|
||||
)
|
||||
|
||||
const DIPS_CHAISE = ex(
|
||||
'Dips sur chaise', 'Chair Dips',
|
||||
'Si inconfort à l\'avant de l\'épaule : réduire l\'amplitude. Contre-indiqué si tendinopathie du biceps.',
|
||||
'If anterior shoulder discomfort: reduce range. Contraindicated with biceps tendinopathy.',
|
||||
)
|
||||
|
||||
const SUPERMAN_YWT = ex(
|
||||
'Superman en Y·W·T', 'Y-W-T Superman',
|
||||
'Travail intense des rhomboïdes et trapèzes inférieurs. Muscles posturaux clés.',
|
||||
'Intense work for rhomboids and lower traps. Key postural muscles.',
|
||||
)
|
||||
|
||||
const BURPEE_MODIFIE = ex(
|
||||
'Burpee modifié', 'Modified Burpee',
|
||||
'Marcher les pieds en arrière jusqu\'à planche, marcher retour, se relever. Pas de saut.',
|
||||
'Walk feet back to plank, walk return, stand up. No jump.',
|
||||
)
|
||||
|
||||
const MC_RAPIDE = ex(
|
||||
'Mountain climber rapide', 'Fast Mountain Climber',
|
||||
'Les hanches ne remontent pas au-dessus des épaules. Regard vers le sol.',
|
||||
'Hips don\'t rise above shoulders. Eyes toward the floor.',
|
||||
)
|
||||
|
||||
const SKATERS = ex(
|
||||
'Skaters', 'Skaters',
|
||||
'Atterrissage sur un pied — demande bonne proprioception de cheville. En cas d\'entorse récente : éviter.',
|
||||
'Single-foot landing — requires good ankle proprioception. If recent sprain: avoid.',
|
||||
)
|
||||
|
||||
const PLANCHE_DOWNDOG = ex(
|
||||
'Planche to downward dog', 'Plank to Downward Dog',
|
||||
'Tirer les talons vers le sol en V inversé pour étirer les mollets.',
|
||||
'Pull heels toward floor in inverted V to stretch calves.',
|
||||
)
|
||||
|
||||
const HIGH_KNEES = ex(
|
||||
'High knees', 'High Knees',
|
||||
'Le but est le rythme, pas la hauteur maximale. Les bras pompeurs contribuent à 15% de la dépense.',
|
||||
'Focus on rhythm, not max height. Pumping arms contribute 15% of calorie burn.',
|
||||
)
|
||||
|
||||
const BEAR_CRAWL = ex(
|
||||
'Bear crawl sur place', 'Stationary Bear Crawl',
|
||||
'Très intense pour le gainage et les épaules. Genoux à 3 cm du sol.',
|
||||
'Very intense for core and shoulders. Knees 3cm off the floor.',
|
||||
)
|
||||
|
||||
const BURPEE_COMPLET = ex(
|
||||
'Burpee complet', 'Full Burpee',
|
||||
'Ne jamais arrondir violemment le dos sous fatigue.',
|
||||
'Never violently round your back under fatigue.',
|
||||
)
|
||||
|
||||
const HOLLOW_BODY = ex(
|
||||
'Hollow body', 'Hollow Body Hold',
|
||||
'Gainage profond issu de la gymnastique artistique — le plus efficace pour les abdominaux profonds.',
|
||||
'Deep core hold from artistic gymnastics — most effective for deep abs.',
|
||||
)
|
||||
|
||||
const V_SIT = ex(
|
||||
'V-sit hold', 'V-sit Hold',
|
||||
'Si douleur lombaire : fléchir légèrement les genoux.',
|
||||
'If lower back pain: slightly bend knees.',
|
||||
)
|
||||
|
||||
const THRUSTER = ex(
|
||||
'Thruster', 'Thruster (Bodyweight)',
|
||||
'La montée en pression intra-abdominale est importante — expirer lors de la poussée vers le haut.',
|
||||
'Intra-abdominal pressure buildup matters — exhale on the upward push.',
|
||||
)
|
||||
|
||||
const PISTOL_ASSIST = ex(
|
||||
'Pistol squat assisté', 'Assisted Pistol Squat',
|
||||
'Révélateur des asymétries de force et de mobilité.',
|
||||
'Reveals strength and mobility asymmetries.',
|
||||
)
|
||||
|
||||
const ARCHER_PUSHUP = ex(
|
||||
'Archer push-up', 'Archer Push-up',
|
||||
'Maintenir l\'alignement corps-bras porteur parfait.',
|
||||
'Maintain perfect body-supporting arm alignment.',
|
||||
)
|
||||
|
||||
// ─── Week 1: Transition avec impacts (3 blocs/séance) ─────────
|
||||
|
||||
const week1: TabataWeek = {
|
||||
weekNumber: 1,
|
||||
title: 'Transition avec impacts',
|
||||
titleEn: 'Transition to Impact',
|
||||
description: '3 blocs tabata + 1 min récupération entre chaque. Durée totale : ~35 minutes.',
|
||||
descriptionEn: '3 tabata blocks + 1 min recovery between each. Total: ~35 min.',
|
||||
focus: 'Introduction de la plyométrie contrôlée, travail unilatéral',
|
||||
focusEn: 'Introduction to controlled plyometrics, unilateral work',
|
||||
isDeload: false,
|
||||
sessions: [
|
||||
{
|
||||
id: 'int-w1-s1', week: 1, order: 1,
|
||||
title: 'Membres inférieurs plyométriques', titleEn: 'Plyometric Lower Body',
|
||||
description: 'Squat jump, fente sautée, step-up explosif.', descriptionEn: 'Squat jump, jump lunge, explosive step-up.',
|
||||
focus: ['Quadriceps', 'Fessiers', 'Puissance'], focusEn: ['Quads', 'Glutes', 'Power'],
|
||||
warmup: { movements: [
|
||||
{ name: 'Marche rapide bras actifs', nameEn: 'Brisk Walk Active Arms', duration: 60 },
|
||||
{ name: 'Squats lents x10', nameEn: 'Slow Squats x10', duration: 60 },
|
||||
{ name: 'Fentes alternées lentes x8', nameEn: 'Slow Alt Lunges x8', duration: 60 },
|
||||
{ name: 'Sauts très légers sur place', nameEn: 'Very Light Jumps', duration: 30 },
|
||||
{ name: 'Hip circles', nameEn: 'Hip Circles', duration: 60 },
|
||||
], totalDuration: 270 },
|
||||
blocks: [
|
||||
block('int-w1-s1-b1', SQUAT_JUMP, PONT_UNILAT),
|
||||
block('int-w1-s1-b2', FENTE_SAUTEE, ISO_SQUAT),
|
||||
block('int-w1-s1-b3', STEP_EXPLOSIF, GLUTE_KICKBACK),
|
||||
],
|
||||
cooldown: { movements: [
|
||||
{ name: 'Étirement psoas en fente basse', nameEn: 'Low Lunge Psoas Stretch', duration: 60 },
|
||||
{ name: 'Étirement mollets contre mur', nameEn: 'Wall Calf Stretch', duration: 45 },
|
||||
{ name: 'Automassage quadriceps', nameEn: 'Self-massage Quads', duration: 60 },
|
||||
{ name: 'Respiration guidée', nameEn: 'Guided Breathing', duration: 30 },
|
||||
], totalDuration: 195 },
|
||||
equipment: ['Marche ou step bas'], totalRounds: 24, totalDuration: 35, calories: 135,
|
||||
},
|
||||
{
|
||||
id: 'int-w1-s2', week: 1, order: 2,
|
||||
title: 'Haut du corps & gainage dynamique', titleEn: 'Dynamic Upper Body & Core',
|
||||
description: 'Pompes classiques, pompes rotation, dips sur chaise.', descriptionEn: 'Full push-ups, T-push-ups, chair dips.',
|
||||
focus: ['Pectoraux', 'Épaules', 'Triceps'], focusEn: ['Chest', 'Shoulders', 'Triceps'],
|
||||
warmup: { movements: [
|
||||
{ name: 'Rotations épaules avec serviette', nameEn: 'Shoulder Rotations with Towel', duration: 60 },
|
||||
{ name: 'Pompes lentes x8', nameEn: 'Slow Push-ups x8', duration: 60 },
|
||||
{ name: 'Planche dynamique haute↔basse', nameEn: 'Dynamic Plank High↔Low', duration: 60 },
|
||||
{ name: 'Dead bug lent', nameEn: 'Slow Dead Bug', duration: 60 },
|
||||
], totalDuration: 240 },
|
||||
blocks: [
|
||||
block('int-w1-s2-b1', POMPE_CLASSIQUE, RENEGADE_ROW),
|
||||
block('int-w1-s2-b2', POMPE_ROTATION_T, PLANCHE_TAP),
|
||||
block('int-w1-s2-b3', DIPS_CHAISE, SUPERMAN_YWT),
|
||||
],
|
||||
cooldown: { movements: [
|
||||
{ name: 'Étirement pectoraux en porte', nameEn: 'Doorway Chest Stretch', duration: 45 },
|
||||
{ name: 'Étirement grand dorsal', nameEn: 'Lat Stretch', duration: 45 },
|
||||
{ name: 'Mobilisation cervicale douce', nameEn: 'Gentle Neck Mobilization', duration: 60 },
|
||||
], totalDuration: 150 },
|
||||
equipment: ['Chaise stable'], totalRounds: 24, totalDuration: 35, calories: 125,
|
||||
},
|
||||
{
|
||||
id: 'int-w1-s3', week: 1, order: 3,
|
||||
title: 'Corps entier cardio dominant', titleEn: 'Cardio Full Body',
|
||||
description: 'Burpee modifié, skaters, high knees, bear crawl.', descriptionEn: 'Modified burpee, skaters, high knees, bear crawl.',
|
||||
focus: ['Cardio', 'Coordination', 'Endurance'], focusEn: ['Cardio', 'Coordination', 'Endurance'],
|
||||
warmup: { movements: [
|
||||
{ name: 'Jumping jacks', nameEn: 'Jumping Jacks', duration: 60 },
|
||||
{ name: 'High knees légers', nameEn: 'Light High Knees', duration: 60 },
|
||||
{ name: 'Fentes dynamiques', nameEn: 'Dynamic Lunges', duration: 60 },
|
||||
{ name: 'Cat-cow', nameEn: 'Cat-Cow', duration: 60 },
|
||||
], totalDuration: 240 },
|
||||
blocks: [
|
||||
block('int-w1-s3-b1', BURPEE_MODIFIE, MC_RAPIDE),
|
||||
block('int-w1-s3-b2', SKATERS, PLANCHE_DOWNDOG),
|
||||
block('int-w1-s3-b3', HIGH_KNEES, BEAR_CRAWL),
|
||||
],
|
||||
cooldown: { movements: [
|
||||
{ name: 'Foam roller mollets', nameEn: 'Foam Roll Calves', duration: 60 },
|
||||
{ name: 'Pigeon yoga (chaque côté)', nameEn: 'Pigeon Pose (each side)', duration: 45 },
|
||||
{ name: 'Respiration 4-7-8 × 4', nameEn: '4-7-8 Breathing × 4', duration: 60 },
|
||||
], totalDuration: 165 },
|
||||
equipment: [], totalRounds: 24, totalDuration: 35, calories: 140,
|
||||
},
|
||||
{
|
||||
id: 'int-w1-s4', week: 1, order: 4,
|
||||
title: 'Mobilité & récupération active', titleEn: 'Active Recovery & Mobility',
|
||||
description: 'Pas de blocs 20/10. Mobilité articulaire + étirements + respiration guidée.', descriptionEn: 'No 20/10 blocks. Joint mobility + stretching + guided breathing.',
|
||||
focus: ['Récupération', 'Mobilité', 'Respiration'], focusEn: ['Recovery', 'Mobility', 'Breathing'],
|
||||
warmup: { movements: [], totalDuration: 0 },
|
||||
blocks: [], // Special: no tabata blocks
|
||||
cooldown: { movements: [], totalDuration: 0 },
|
||||
equipment: [], totalRounds: 0, totalDuration: 25, calories: 30,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// ─── Week 2: Montée en densité (4 blocs/séance) ──────────────
|
||||
|
||||
const week2: TabataWeek = {
|
||||
weekNumber: 2,
|
||||
title: 'Montée en densité', titleEn: 'Increasing Density',
|
||||
description: '4 blocs tabata + 1 min récup. Nouveaux: Thruster, Burpee complet, Hollow body.',
|
||||
descriptionEn: '4 tabata blocks + 1 min recovery. New: Thruster, Full Burpee, Hollow body.',
|
||||
focus: 'Augmentation du volume, nouveaux exercices composés',
|
||||
focusEn: 'Volume increase, new compound exercises',
|
||||
isDeload: false,
|
||||
sessions: [
|
||||
{
|
||||
id: 'int-w2-s1', week: 2, order: 1,
|
||||
title: 'Corps entier — Densité', titleEn: 'Full Body — Density',
|
||||
description: 'Squat jump, fente sautée, thruster, burpee complet.', descriptionEn: 'Squat jump, jump lunge, thruster, full burpee.',
|
||||
focus: ['Puissance', 'Endurance', 'Composé'], focusEn: ['Power', 'Endurance', 'Compound'],
|
||||
warmup: { movements: [
|
||||
{ name: 'Marche rapide bras actifs', nameEn: 'Brisk Walk Active Arms', duration: 60 },
|
||||
{ name: 'Squats d\'activation x10', nameEn: 'Activation Squats x10', duration: 60 },
|
||||
{ name: 'Fentes dynamiques x8', nameEn: 'Dynamic Lunges x8', duration: 60 },
|
||||
{ name: '2 burpees complets', nameEn: '2 Full Burpees', duration: 30 },
|
||||
], totalDuration: 210 },
|
||||
blocks: [
|
||||
block('int-w2-s1-b1', SQUAT_JUMP, PONT_UNILAT),
|
||||
block('int-w2-s1-b2', FENTE_SAUTEE, ISO_SQUAT),
|
||||
block('int-w2-s1-b3', THRUSTER, HOLLOW_BODY),
|
||||
block('int-w2-s1-b4', BURPEE_COMPLET, V_SIT),
|
||||
],
|
||||
cooldown: { movements: [
|
||||
{ name: 'Étirement psoas en fente basse', nameEn: 'Low Lunge Psoas Stretch', duration: 60 },
|
||||
{ name: 'Étirement ischio-jambiers', nameEn: 'Hamstring Stretch', duration: 45 },
|
||||
{ name: 'Respiration guidée', nameEn: 'Guided Breathing', duration: 30 },
|
||||
], totalDuration: 135 },
|
||||
equipment: [], totalRounds: 32, totalDuration: 40, calories: 185,
|
||||
},
|
||||
{
|
||||
id: 'int-w2-s2', week: 2, order: 2,
|
||||
title: 'Haut du corps — Densité', titleEn: 'Upper Body — Density',
|
||||
description: 'Pompes classiques, pompes rotation, dips, archer push-up.', descriptionEn: 'Full push-ups, T-push-ups, dips, archer push-up.',
|
||||
focus: ['Force', 'Stabilité', 'Endurance'], focusEn: ['Strength', 'Stability', 'Endurance'],
|
||||
warmup: { movements: [
|
||||
{ name: 'Rotations épaules', nameEn: 'Shoulder Rotations', duration: 60 },
|
||||
{ name: 'Pompes lentes x8', nameEn: 'Slow Push-ups x8', duration: 60 },
|
||||
{ name: 'Planche dynamique', nameEn: 'Dynamic Plank', duration: 60 },
|
||||
], totalDuration: 180 },
|
||||
blocks: [
|
||||
block('int-w2-s2-b1', POMPE_CLASSIQUE, PLANCHE_TAP),
|
||||
block('int-w2-s2-b2', POMPE_ROTATION_T, HOLLOW_BODY),
|
||||
block('int-w2-s2-b3', DIPS_CHAISE, SUPERMAN_YWT),
|
||||
block('int-w2-s2-b4', ARCHER_PUSHUP, V_SIT),
|
||||
],
|
||||
cooldown: { movements: [
|
||||
{ name: 'Étirement pectoraux', nameEn: 'Chest Stretch', duration: 45 },
|
||||
{ name: 'Étirement triceps', nameEn: 'Triceps Stretch', duration: 30 },
|
||||
{ name: 'Respiration guidée', nameEn: 'Guided Breathing', duration: 30 },
|
||||
], totalDuration: 105 },
|
||||
equipment: ['Chaise stable'], totalRounds: 32, totalDuration: 40, calories: 170,
|
||||
},
|
||||
{
|
||||
id: 'int-w2-s3', week: 2, order: 3,
|
||||
title: 'Cardio — Densité', titleEn: 'Cardio — Density',
|
||||
description: 'Burpee complet, skaters, high knees, MC rapide.', descriptionEn: 'Full burpee, skaters, high knees, fast MC.',
|
||||
focus: ['Cardio', 'Agilité', 'Vitesse'], focusEn: ['Cardio', 'Agility', 'Speed'],
|
||||
warmup: { movements: [
|
||||
{ name: 'Jumping jacks', nameEn: 'Jumping Jacks', duration: 60 },
|
||||
{ name: 'High knees légers', nameEn: 'Light High Knees', duration: 60 },
|
||||
{ name: 'Squats activation', nameEn: 'Activation Squats', duration: 60 },
|
||||
], totalDuration: 180 },
|
||||
blocks: [
|
||||
block('int-w2-s3-b1', BURPEE_COMPLET, MC_RAPIDE),
|
||||
block('int-w2-s3-b2', SKATERS, BEAR_CRAWL),
|
||||
block('int-w2-s3-b3', HIGH_KNEES, PLANCHE_DOWNDOG),
|
||||
block('int-w2-s3-b4', BURPEE_MODIFIE, HOLLOW_BODY),
|
||||
],
|
||||
cooldown: { movements: [
|
||||
{ name: 'Foam roller IT band', nameEn: 'Foam Roll IT Band', duration: 60 },
|
||||
{ name: 'Pigeon yoga', nameEn: 'Pigeon Pose', duration: 60 },
|
||||
{ name: 'Respiration 4-7-8', nameEn: '4-7-8 Breathing', duration: 60 },
|
||||
], totalDuration: 180 },
|
||||
equipment: [], totalRounds: 32, totalDuration: 40, calories: 195,
|
||||
},
|
||||
{
|
||||
id: 'int-w2-s4', week: 2, order: 4,
|
||||
title: 'Force & gainage', titleEn: 'Strength & Core',
|
||||
description: 'Thruster, pistol squat assisté, archer push-up, hollow body.', descriptionEn: 'Thruster, assisted pistol squat, archer push-up, hollow body.',
|
||||
focus: ['Force unilatérale', 'Gainage profond'], focusEn: ['Unilateral strength', 'Deep core'],
|
||||
warmup: { movements: [
|
||||
{ name: 'Marche active', nameEn: 'Active Walk', duration: 60 },
|
||||
{ name: 'Squats profonds x8', nameEn: 'Deep Squats x8', duration: 60 },
|
||||
{ name: 'Pompes x6', nameEn: 'Push-ups x6', duration: 60 },
|
||||
{ name: 'Dead bug x8', nameEn: 'Dead Bug x8', duration: 60 },
|
||||
], totalDuration: 240 },
|
||||
blocks: [
|
||||
block('int-w2-s4-b1', THRUSTER, PISTOL_ASSIST),
|
||||
block('int-w2-s4-b2', ARCHER_PUSHUP, HOLLOW_BODY),
|
||||
block('int-w2-s4-b3', FENTE_SAUTEE, GLUTE_KICKBACK),
|
||||
block('int-w2-s4-b4', POMPE_CLASSIQUE, V_SIT),
|
||||
],
|
||||
cooldown: { movements: [
|
||||
{ name: 'Étirement psoas', nameEn: 'Psoas Stretch', duration: 60 },
|
||||
{ name: 'Étirement mollets', nameEn: 'Calf Stretch', duration: 45 },
|
||||
{ name: 'Respiration guidée', nameEn: 'Guided Breathing', duration: 30 },
|
||||
], totalDuration: 135 },
|
||||
equipment: [], totalRounds: 32, totalDuration: 40, calories: 175,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// ─── Week 3 & 4 (simplified — full content in production) ─────
|
||||
|
||||
const week3: TabataWeek = {
|
||||
weekNumber: 3,
|
||||
title: 'Intensité maximale', titleEn: 'Maximum Intensity',
|
||||
description: '4 blocs/séance. Exercices signature: Burpee saut latéral, Pistol squat, Archer push-up.',
|
||||
descriptionEn: '4 blocks/session. Signature moves: Lateral burpee, Pistol squat, Archer push-up.',
|
||||
focus: 'Intensité maximale, mouvements complexes',
|
||||
focusEn: 'Maximum intensity, complex movements',
|
||||
isDeload: false,
|
||||
sessions: [
|
||||
{
|
||||
id: 'int-w3-s1', week: 3, order: 1,
|
||||
title: 'Jambes puissance max', titleEn: 'Max Leg Power',
|
||||
description: 'Squat jump, pistol squat assisté, fente sautée, step-up explosif.', descriptionEn: 'Squat jump, assisted pistol squat, jump lunge, explosive step-up.',
|
||||
focus: ['Puissance', 'Unilatéral'], focusEn: ['Power', 'Unilateral'],
|
||||
warmup: { movements: [
|
||||
{ name: 'Marche rapide', nameEn: 'Brisk Walk', duration: 60 },
|
||||
{ name: 'Squats x10', nameEn: 'Squats x10', duration: 60 },
|
||||
{ name: 'Fentes dynamiques', nameEn: 'Dynamic Lunges', duration: 60 },
|
||||
], totalDuration: 180 },
|
||||
blocks: [
|
||||
block('int-w3-s1-b1', SQUAT_JUMP, PISTOL_ASSIST),
|
||||
block('int-w3-s1-b2', FENTE_SAUTEE, PONT_UNILAT),
|
||||
block('int-w3-s1-b3', STEP_EXPLOSIF, ISO_SQUAT),
|
||||
block('int-w3-s1-b4', THRUSTER, GLUTE_KICKBACK),
|
||||
],
|
||||
cooldown: { movements: [
|
||||
{ name: 'Étirement psoas', nameEn: 'Psoas Stretch', duration: 60 },
|
||||
{ name: 'Étirement mollets', nameEn: 'Calf Stretch', duration: 45 },
|
||||
{ name: 'Respiration', nameEn: 'Breathing', duration: 30 },
|
||||
], totalDuration: 135 },
|
||||
equipment: ['Marche/step'], totalRounds: 32, totalDuration: 40, calories: 190,
|
||||
},
|
||||
{
|
||||
id: 'int-w3-s2', week: 3, order: 2,
|
||||
title: 'Haut du corps avancé', titleEn: 'Advanced Upper Body',
|
||||
description: 'Archer push-up, pompe rotation T, dips, pompes classiques.', descriptionEn: 'Archer push-up, T-push-up, dips, full push-ups.',
|
||||
focus: ['Force', 'Stabilité scapulaire'], focusEn: ['Strength', 'Scapular Stability'],
|
||||
warmup: { movements: [
|
||||
{ name: 'Rotations épaules', nameEn: 'Shoulder Rotations', duration: 60 },
|
||||
{ name: 'Pompes x8', nameEn: 'Push-ups x8', duration: 60 },
|
||||
{ name: 'Planche dynamique', nameEn: 'Dynamic Plank', duration: 60 },
|
||||
], totalDuration: 180 },
|
||||
blocks: [
|
||||
block('int-w3-s2-b1', ARCHER_PUSHUP, PLANCHE_TAP),
|
||||
block('int-w3-s2-b2', POMPE_ROTATION_T, HOLLOW_BODY),
|
||||
block('int-w3-s2-b3', DIPS_CHAISE, SUPERMAN_YWT),
|
||||
block('int-w3-s2-b4', POMPE_CLASSIQUE, V_SIT),
|
||||
],
|
||||
cooldown: { movements: [
|
||||
{ name: 'Étirement pectoraux', nameEn: 'Chest Stretch', duration: 45 },
|
||||
{ name: 'Étirement triceps', nameEn: 'Triceps Stretch', duration: 30 },
|
||||
{ name: 'Respiration', nameEn: 'Breathing', duration: 30 },
|
||||
], totalDuration: 105 },
|
||||
equipment: ['Chaise'], totalRounds: 32, totalDuration: 40, calories: 180,
|
||||
},
|
||||
{
|
||||
id: 'int-w3-s3', week: 3, order: 3,
|
||||
title: 'Cardio extrême', titleEn: 'Extreme Cardio',
|
||||
description: 'Burpee complet, high knees, skaters, MC rapide.', descriptionEn: 'Full burpee, high knees, skaters, fast MC.',
|
||||
focus: ['Cardio max', 'Endurance'], focusEn: ['Max cardio', 'Endurance'],
|
||||
warmup: { movements: [
|
||||
{ name: 'Jumping jacks', nameEn: 'Jumping Jacks', duration: 60 },
|
||||
{ name: 'High knees', nameEn: 'High Knees', duration: 60 },
|
||||
{ name: '2 burpees complets', nameEn: '2 Full Burpees', duration: 30 },
|
||||
], totalDuration: 150 },
|
||||
blocks: [
|
||||
block('int-w3-s3-b1', BURPEE_COMPLET, MC_RAPIDE),
|
||||
block('int-w3-s3-b2', HIGH_KNEES, BEAR_CRAWL),
|
||||
block('int-w3-s3-b3', SKATERS, PLANCHE_DOWNDOG),
|
||||
block('int-w3-s3-b4', BURPEE_COMPLET, HOLLOW_BODY),
|
||||
],
|
||||
cooldown: { movements: [
|
||||
{ name: 'Foam roller', nameEn: 'Foam Roll', duration: 60 },
|
||||
{ name: 'Respiration 4-7-8', nameEn: '4-7-8 Breathing', duration: 60 },
|
||||
], totalDuration: 120 },
|
||||
equipment: [], totalRounds: 32, totalDuration: 40, calories: 200,
|
||||
},
|
||||
{
|
||||
id: 'int-w3-s4', week: 3, order: 4,
|
||||
title: 'Mix force + cardio', titleEn: 'Strength + Cardio Mix',
|
||||
description: 'Thruster, squat jump, archer push-up, burpee.', descriptionEn: 'Thruster, squat jump, archer push-up, burpee.',
|
||||
focus: ['Composé', 'Endurance de force'], focusEn: ['Compound', 'Strength Endurance'],
|
||||
warmup: { movements: [
|
||||
{ name: 'Marche active', nameEn: 'Active Walk', duration: 60 },
|
||||
{ name: 'Squats x8', nameEn: 'Squats x8', duration: 60 },
|
||||
{ name: 'Pompes x6', nameEn: 'Push-ups x6', duration: 60 },
|
||||
], totalDuration: 180 },
|
||||
blocks: [
|
||||
block('int-w3-s4-b1', THRUSTER, PISTOL_ASSIST),
|
||||
block('int-w3-s4-b2', SQUAT_JUMP, FENTE_SAUTEE),
|
||||
block('int-w3-s4-b3', ARCHER_PUSHUP, V_SIT),
|
||||
block('int-w3-s4-b4', BURPEE_COMPLET, HOLLOW_BODY),
|
||||
],
|
||||
cooldown: { movements: [
|
||||
{ name: 'Étirement complet', nameEn: 'Full Stretch', duration: 60 },
|
||||
{ name: 'Respiration', nameEn: 'Breathing', duration: 30 },
|
||||
], totalDuration: 90 },
|
||||
equipment: [], totalRounds: 32, totalDuration: 40, calories: 195,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const week4: TabataWeek = {
|
||||
weekNumber: 4,
|
||||
title: 'Décharge & bilan', titleEn: 'Deload & Assessment',
|
||||
description: 'Retour à 3 blocs. Exercices maîtrisés, focus technique. Tests de fin de programme.',
|
||||
descriptionEn: 'Back to 3 blocks. Mastered exercises, technique focus. End-of-program tests.',
|
||||
focus: 'Technique parfaite, récupération, tests',
|
||||
focusEn: 'Perfect technique, recovery, tests',
|
||||
isDeload: true,
|
||||
sessions: [
|
||||
{
|
||||
id: 'int-w4-s1', week: 4, order: 1,
|
||||
title: 'Jambes (décharge)', titleEn: 'Legs (Deload)',
|
||||
description: 'Squat jump maîtrisé, fente contrôlée, pont unilatéral.', descriptionEn: 'Controlled squat jump, controlled lunge, single-leg bridge.',
|
||||
focus: ['Technique', 'Contrôle'], focusEn: ['Technique', 'Control'],
|
||||
warmup: { movements: [
|
||||
{ name: 'Marche sur place', nameEn: 'March in Place', duration: 60 },
|
||||
{ name: 'Squats lents', nameEn: 'Slow Squats', duration: 60 },
|
||||
{ name: 'Fentes statiques', nameEn: 'Static Lunges', duration: 60 },
|
||||
], totalDuration: 180 },
|
||||
blocks: [
|
||||
block('int-w4-s1-b1', SQUAT_JUMP, PONT_UNILAT),
|
||||
block('int-w4-s1-b2', FENTE_SAUTEE, ISO_SQUAT),
|
||||
block('int-w4-s1-b3', STEP_EXPLOSIF, GLUTE_KICKBACK),
|
||||
],
|
||||
cooldown: { movements: [
|
||||
{ name: 'Étirement complet jambes', nameEn: 'Full Leg Stretch', duration: 90 },
|
||||
{ name: 'Respiration', nameEn: 'Breathing', duration: 30 },
|
||||
], totalDuration: 120 },
|
||||
equipment: ['Marche/step'], totalRounds: 24, totalDuration: 35, calories: 140,
|
||||
},
|
||||
{
|
||||
id: 'int-w4-s2', week: 4, order: 2,
|
||||
title: 'Haut du corps (décharge)', titleEn: 'Upper Body (Deload)',
|
||||
description: 'Pompes classiques, planche tap, dips modérés.', descriptionEn: 'Full push-ups, shoulder taps, moderate dips.',
|
||||
focus: ['Qualité', 'Stabilité'], focusEn: ['Quality', 'Stability'],
|
||||
warmup: { movements: [
|
||||
{ name: 'Rotations épaules', nameEn: 'Shoulder Rotations', duration: 60 },
|
||||
{ name: 'Pompes genoux x8', nameEn: 'Knee Push-ups x8', duration: 60 },
|
||||
], totalDuration: 120 },
|
||||
blocks: [
|
||||
block('int-w4-s2-b1', POMPE_CLASSIQUE, PLANCHE_TAP),
|
||||
block('int-w4-s2-b2', POMPE_ROTATION_T, SUPERMAN_YWT),
|
||||
block('int-w4-s2-b3', DIPS_CHAISE, HOLLOW_BODY),
|
||||
],
|
||||
cooldown: { movements: [
|
||||
{ name: 'Étirement haut du corps', nameEn: 'Upper Body Stretch', duration: 90 },
|
||||
{ name: 'Respiration', nameEn: 'Breathing', duration: 30 },
|
||||
], totalDuration: 120 },
|
||||
equipment: ['Chaise'], totalRounds: 24, totalDuration: 35, calories: 130,
|
||||
},
|
||||
{
|
||||
id: 'int-w4-s3', week: 4, order: 3,
|
||||
title: 'Cardio léger + tests', titleEn: 'Light Cardio + Tests',
|
||||
description: 'Burpee modéré, MC rapide, skaters légers.', descriptionEn: 'Moderate burpee, fast MC, light skaters.',
|
||||
focus: ['Récupération active', 'Bilan'], focusEn: ['Active Recovery', 'Assessment'],
|
||||
warmup: { movements: [
|
||||
{ name: 'Marche active', nameEn: 'Active Walk', duration: 60 },
|
||||
{ name: 'Mobilisation articulaire', nameEn: 'Joint Mobility', duration: 60 },
|
||||
], totalDuration: 120 },
|
||||
blocks: [
|
||||
block('int-w4-s3-b1', BURPEE_MODIFIE, MC_RAPIDE),
|
||||
block('int-w4-s3-b2', SKATERS, PLANCHE_DOWNDOG),
|
||||
block('int-w4-s3-b3', HIGH_KNEES, BEAR_CRAWL),
|
||||
],
|
||||
cooldown: { movements: [
|
||||
{ name: 'Étirement complet', nameEn: 'Full Stretch', duration: 90 },
|
||||
{ name: 'Respiration 4-7-8', nameEn: '4-7-8 Breathing', duration: 60 },
|
||||
], totalDuration: 150 },
|
||||
equipment: [], totalRounds: 24, totalDuration: 35, calories: 135,
|
||||
},
|
||||
{
|
||||
id: 'int-w4-s4', week: 4, order: 4,
|
||||
title: 'Bilan final', titleEn: 'Final Assessment',
|
||||
description: 'Tests: planche 60s, burpees 1min, squat jump ×8, asymétrie G/D.', descriptionEn: 'Tests: 60s plank, 1min burpees, 8 rounds squat jump, L/R asymmetry.',
|
||||
focus: ['Tests de passage', 'Évaluation'], focusEn: ['Passing Tests', 'Assessment'],
|
||||
warmup: { movements: [
|
||||
{ name: 'Marche active', nameEn: 'Active Walk', duration: 60 },
|
||||
{ name: 'Mobilisation complète', nameEn: 'Full Mobility', duration: 60 },
|
||||
], totalDuration: 120 },
|
||||
blocks: [
|
||||
block('int-w4-s4-b1', THRUSTER, HOLLOW_BODY),
|
||||
block('int-w4-s4-b2', ARCHER_PUSHUP, V_SIT),
|
||||
block('int-w4-s4-b3', PISTOL_ASSIST, SUPERMAN_YWT),
|
||||
],
|
||||
cooldown: { movements: [
|
||||
{ name: 'Savasana + respiration', nameEn: 'Savasana + Breathing', duration: 120 },
|
||||
], totalDuration: 120 },
|
||||
equipment: [], totalRounds: 24, totalDuration: 35, calories: 125,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// ─── Export ─────────────────────────────────────────────────────
|
||||
|
||||
export const INTERMEDIAIRE_PROGRAM: TabataProgram = {
|
||||
id: 'intermediaire',
|
||||
title: 'Intermédiaire',
|
||||
titleEn: 'Intermediate',
|
||||
description: 'Introduire la plyométrie contrôlée, intensifier le travail unilatéral, passer à 4 blocs tabata.',
|
||||
descriptionEn: 'Introduce controlled plyometrics, intensify unilateral work, progress to 4 tabata blocks.',
|
||||
tier: 'premium',
|
||||
accentColor: '#85C7F2',
|
||||
icon: 'flame',
|
||||
durationWeeks: 4,
|
||||
sessionsPerWeek: 4,
|
||||
totalSessions: 16,
|
||||
equipment: { required: [], optional: ['Marche ou step bas', 'Chaise stable', 'Foam roller'] },
|
||||
focusAreas: ['Plyométrie', 'Gainage dynamique', 'Burpees', 'Force unilatérale'],
|
||||
focusAreasEn: ['Plyometrics', 'Dynamic Core', 'Burpees', 'Unilateral Strength'],
|
||||
principles: [
|
||||
'Réception silencieuse sur avant-pied',
|
||||
'Ne jamais faire 2 séances intenses consécutives',
|
||||
'La fatigue neurologique = réduire d\'une séance',
|
||||
],
|
||||
principlesEn: [
|
||||
'Silent forefoot landing',
|
||||
'Never do 2 intense sessions back-to-back',
|
||||
'Neurological fatigue = reduce by one session',
|
||||
],
|
||||
completionCriteria: [
|
||||
'Planche avant-bras 60 secondes',
|
||||
'12-15 burpees en 1 minute',
|
||||
'Squat jump ×8 rounds rythme maintenu',
|
||||
'Asymétrie G/D < 20% au pistol squat',
|
||||
],
|
||||
completionCriteriaEn: [
|
||||
'60-second forearm plank',
|
||||
'12-15 burpees in 1 minute',
|
||||
'8-round squat jump maintaining pace',
|
||||
'L/R asymmetry < 20% on pistol squat',
|
||||
],
|
||||
nextProgramId: 'avance',
|
||||
weeks: [week1, week2, week3, week4],
|
||||
}
|
||||
|
||||
export const INTERMEDIAIRE_SESSIONS: TabataSession[] = INTERMEDIAIRE_PROGRAM.weeks.flatMap(w => w.sessions)
|
||||
@@ -1,44 +1,25 @@
|
||||
/**
|
||||
* TabataFit Trainer Data
|
||||
* 5 trainers per PRD
|
||||
* 1 male + 1 female trainer
|
||||
*/
|
||||
|
||||
import { Trainer } from '../types'
|
||||
|
||||
export const TRAINERS: Trainer[] = [
|
||||
{
|
||||
id: 'emma',
|
||||
name: 'Emma',
|
||||
specialty: 'Full Body',
|
||||
color: '#FF6B35',
|
||||
id: 'felia',
|
||||
name: 'Félia',
|
||||
gender: 'female',
|
||||
specialty: 'Core',
|
||||
color: '#00C896',
|
||||
workoutCount: 15,
|
||||
},
|
||||
{
|
||||
id: 'jake',
|
||||
name: 'Jake',
|
||||
id: 'felix',
|
||||
name: 'Félix',
|
||||
gender: 'male',
|
||||
specialty: 'Strength',
|
||||
color: '#FFD60A',
|
||||
workoutCount: 12,
|
||||
},
|
||||
{
|
||||
id: 'mia',
|
||||
name: 'Mia',
|
||||
specialty: 'Core',
|
||||
color: '#30D158',
|
||||
workoutCount: 10,
|
||||
},
|
||||
{
|
||||
id: 'alex',
|
||||
name: 'Alex',
|
||||
specialty: 'Cardio',
|
||||
color: '#5AC8FA',
|
||||
workoutCount: 8,
|
||||
},
|
||||
{
|
||||
id: 'sofia',
|
||||
name: 'Sofia',
|
||||
specialty: 'Recovery',
|
||||
color: '#BF5AF2',
|
||||
workoutCount: 5,
|
||||
workoutCount: 15,
|
||||
},
|
||||
]
|
||||
|
||||
292
src/shared/data/workoutPrograms.ts
Normal file
292
src/shared/data/workoutPrograms.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Workout Programs Data Access Layer
|
||||
* Fetches body-zone workout programs from Supabase with offline caching
|
||||
*/
|
||||
|
||||
import { supabase, isSupabaseConfigured } from '../supabase/client'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import type {
|
||||
WorkoutProgram,
|
||||
WorkoutTabata,
|
||||
WorkoutProgramRow,
|
||||
WorkoutTabataRow,
|
||||
BodyZone,
|
||||
} from '../types/workoutProgram'
|
||||
import type { TabataSession, TabataBlock, TabataExercise, TimedMovement } from '../types/program'
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────
|
||||
|
||||
const CACHE_KEY = 'tabatafit-workout-programs-cache-v2'
|
||||
const CACHE_TTL = 1000 * 60 * 60 // 1 hour
|
||||
|
||||
// ─── Row Mappers ────────────────────────────────────────────────
|
||||
|
||||
function tabataRowToWorkoutTabata(row: WorkoutTabataRow): WorkoutTabata {
|
||||
return {
|
||||
id: row.id,
|
||||
position: row.position,
|
||||
exercise1: {
|
||||
name: row.exercise_1_name,
|
||||
nameEn: row.exercise_1_name_en ?? '',
|
||||
tip: row.exercise_1_tip ?? undefined,
|
||||
tipEn: row.exercise_1_tip_en ?? undefined,
|
||||
modification: row.exercise_1_modification ?? undefined,
|
||||
modificationEn: row.exercise_1_modification_en ?? undefined,
|
||||
progression: row.exercise_1_progression ?? undefined,
|
||||
progressionEn: row.exercise_1_progression_en ?? undefined,
|
||||
},
|
||||
exercise2: {
|
||||
name: row.exercise_2_name,
|
||||
nameEn: row.exercise_2_name_en ?? '',
|
||||
tip: row.exercise_2_tip ?? undefined,
|
||||
tipEn: row.exercise_2_tip_en ?? undefined,
|
||||
modification: row.exercise_2_modification ?? undefined,
|
||||
modificationEn: row.exercise_2_modification_en ?? undefined,
|
||||
progression: row.exercise_2_progression ?? undefined,
|
||||
progressionEn: row.exercise_2_progression_en ?? undefined,
|
||||
},
|
||||
rounds: row.rounds,
|
||||
workTime: row.work_time,
|
||||
restTime: row.rest_time,
|
||||
}
|
||||
}
|
||||
|
||||
function rowsToWorkoutProgram(
|
||||
programRow: WorkoutProgramRow,
|
||||
tabataRows: WorkoutTabataRow[],
|
||||
): WorkoutProgram {
|
||||
return {
|
||||
id: programRow.id,
|
||||
title: programRow.title,
|
||||
description: programRow.description,
|
||||
bodyZone: programRow.body_zone,
|
||||
level: programRow.level,
|
||||
isFree: programRow.is_free,
|
||||
musicVibe: programRow.music_vibe ?? 'electronic',
|
||||
estimatedDuration: programRow.estimated_duration,
|
||||
estimatedCalories: programRow.estimated_calories,
|
||||
icon: programRow.icon,
|
||||
accentColor: programRow.accent_color,
|
||||
sortOrder: programRow.sort_order,
|
||||
tabatas: tabataRows
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map(tabataRowToWorkoutTabata),
|
||||
createdAt: programRow.created_at,
|
||||
updatedAt: programRow.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cache ──────────────────────────────────────────────────────
|
||||
|
||||
interface CacheEntry {
|
||||
programs: WorkoutProgram[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
async function getCachedPrograms(): Promise<WorkoutProgram[] | null> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(CACHE_KEY)
|
||||
if (!raw) return null
|
||||
const entry: CacheEntry = JSON.parse(raw)
|
||||
if (Date.now() - entry.timestamp > CACHE_TTL) return null
|
||||
return entry.programs
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function setCachedPrograms(programs: WorkoutProgram[]): Promise<void> {
|
||||
try {
|
||||
const entry: CacheEntry = { programs, timestamp: Date.now() }
|
||||
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(entry))
|
||||
} catch {
|
||||
// Cache write failure is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fetch Functions ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch all workout programs with their tabatas.
|
||||
* Uses cache first, falls back to Supabase.
|
||||
*/
|
||||
export async function fetchAllPrograms(): Promise<WorkoutProgram[]> {
|
||||
// Always fetch fresh from Supabase (cache can become stale after schema changes)
|
||||
if (!isSupabaseConfigured()) {
|
||||
const cached = await getCachedPrograms()
|
||||
return cached ?? []
|
||||
}
|
||||
if (!isSupabaseConfigured()) return []
|
||||
|
||||
const { data: programRows, error: progError } = await supabase
|
||||
.from('workout_programs')
|
||||
.select('*')
|
||||
.order('sort_order')
|
||||
.order('body_zone')
|
||||
.order('level')
|
||||
.returns<WorkoutProgramRow[]>()
|
||||
|
||||
if (progError || !programRows?.length) return []
|
||||
|
||||
const { data: tabataRows, error: tabError } = await supabase
|
||||
.from('program_tabatas')
|
||||
.select('*')
|
||||
.order('position')
|
||||
.returns<WorkoutTabataRow[]>()
|
||||
|
||||
if (tabError || !tabataRows) return []
|
||||
|
||||
// Group tabatas by program
|
||||
const tabatasByProgram = new Map<string, WorkoutTabataRow[]>()
|
||||
for (const t of tabataRows) {
|
||||
const existing = tabatasByProgram.get(t.program_id) ?? []
|
||||
existing.push(t)
|
||||
tabatasByProgram.set(t.program_id, existing)
|
||||
}
|
||||
|
||||
const programs = programRows.map(pr =>
|
||||
rowsToWorkoutProgram(pr, tabatasByProgram.get(pr.id) ?? []),
|
||||
)
|
||||
|
||||
await setCachedPrograms(programs)
|
||||
return programs
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch programs filtered by body zone
|
||||
*/
|
||||
export async function fetchProgramsByBodyZone(
|
||||
bodyZone: BodyZone,
|
||||
): Promise<WorkoutProgram[]> {
|
||||
const all = await fetchAllPrograms()
|
||||
return all.filter(p => p.bodyZone === bodyZone)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single program by ID
|
||||
*/
|
||||
export async function fetchProgramById(
|
||||
id: string,
|
||||
): Promise<WorkoutProgram | null> {
|
||||
const all = await fetchAllPrograms()
|
||||
return all.find(p => p.id === id) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an ID refers to a workout program
|
||||
*/
|
||||
export function isWorkoutProgramId(id: string): boolean {
|
||||
return id.startsWith('wp-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a workout program ID like 'wp-{programId}'
|
||||
*/
|
||||
export function parseWorkoutProgramId(
|
||||
id: string,
|
||||
): { programId: string } | null {
|
||||
if (!id.startsWith('wp-')) return null
|
||||
return { programId: id.slice(3) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an ID for a workout program
|
||||
*/
|
||||
export function buildWorkoutProgramId(programId: string): string {
|
||||
return `wp-${programId}`
|
||||
}
|
||||
|
||||
// ─── Adapter: WorkoutProgram → TabataSession ─────────────────────
|
||||
|
||||
/** Generic warmup movements */
|
||||
const GENERIC_WARMUP: TimedMovement[] = [
|
||||
{ name: 'Jumping jacks', nameEn: 'Jumping jacks', duration: 30 },
|
||||
{ name: 'Rotation des bras', nameEn: 'Arm circles', duration: 30 },
|
||||
{ name: 'Montées de genoux', nameEn: 'High knees', duration: 30 },
|
||||
{ name: 'Squats légers', nameEn: 'Bodyweight squats', duration: 30 },
|
||||
]
|
||||
|
||||
/** Generic cooldown movements */
|
||||
const GENERIC_COOLDOWN: TimedMovement[] = [
|
||||
{ name: 'Étirement des quadriceps', nameEn: 'Quad stretch', duration: 30 },
|
||||
{ name: 'Étirement des ischio-jambiers', nameEn: 'Hamstring stretch', duration: 30 },
|
||||
{ name: 'Respiration profonde', nameEn: 'Deep breathing', duration: 30 },
|
||||
]
|
||||
|
||||
/**
|
||||
* Convert a full WorkoutProgram (all 3 tabatas) to a TabataSession for the player.
|
||||
* All tabata blocks are played sequentially in one session.
|
||||
*/
|
||||
export function workoutProgramToTabataSession(
|
||||
program: WorkoutProgram,
|
||||
): TabataSession {
|
||||
const blocks: TabataBlock[] = program.tabatas.map((tabata) => {
|
||||
const oddExercise: TabataExercise = {
|
||||
name: tabata.exercise1.name,
|
||||
nameEn: tabata.exercise1.nameEn,
|
||||
conseil: tabata.exercise1.tip ?? '',
|
||||
conseilEn: tabata.exercise1.tipEn ?? '',
|
||||
modification: tabata.exercise1.modification,
|
||||
modificationEn: tabata.exercise1.modificationEn,
|
||||
progression: tabata.exercise1.progression,
|
||||
progressionEn: tabata.exercise1.progressionEn,
|
||||
}
|
||||
|
||||
const evenExercise: TabataExercise = {
|
||||
name: tabata.exercise2.name,
|
||||
nameEn: tabata.exercise2.nameEn,
|
||||
conseil: tabata.exercise2.tip ?? '',
|
||||
conseilEn: tabata.exercise2.tipEn ?? '',
|
||||
modification: tabata.exercise2.modification,
|
||||
modificationEn: tabata.exercise2.modificationEn,
|
||||
progression: tabata.exercise2.progression,
|
||||
progressionEn: tabata.exercise2.progressionEn,
|
||||
}
|
||||
|
||||
return {
|
||||
id: tabata.id,
|
||||
oddExercise,
|
||||
evenExercise,
|
||||
rounds: tabata.rounds,
|
||||
workTime: tabata.workTime,
|
||||
restTime: tabata.restTime,
|
||||
}
|
||||
})
|
||||
|
||||
// Calculate total duration: warmup (2min) + all tabatas + inter-block rests (1min each) + cooldown (1.5min)
|
||||
const tabatasDuration = blocks.reduce(
|
||||
(sum, b) => sum + (b.rounds * (b.workTime + b.restTime)) / 60,
|
||||
0,
|
||||
)
|
||||
const interBlockRest = Math.max(0, blocks.length - 1) // 1 min between blocks
|
||||
const totalDuration = 2 + tabatasDuration + interBlockRest + 1.5
|
||||
|
||||
const totalRounds = blocks.reduce((sum, b) => sum + b.rounds, 0)
|
||||
const calorieMultiplier = program.level === 'Advanced' ? 12 : program.level === 'Intermediate' ? 9 : 7
|
||||
|
||||
return {
|
||||
id: buildWorkoutProgramId(program.id),
|
||||
week: 1,
|
||||
order: 1,
|
||||
title: program.title,
|
||||
titleEn: program.title,
|
||||
description: program.description ?? '',
|
||||
descriptionEn: program.description ?? '',
|
||||
focus: [program.bodyZone],
|
||||
focusEn: [program.bodyZone],
|
||||
warmup: {
|
||||
movements: GENERIC_WARMUP,
|
||||
totalDuration: 120,
|
||||
},
|
||||
blocks,
|
||||
cooldown: {
|
||||
movements: GENERIC_COOLDOWN,
|
||||
totalDuration: 90,
|
||||
},
|
||||
equipment: [],
|
||||
totalRounds,
|
||||
totalDuration: Math.ceil(totalDuration),
|
||||
calories: Math.ceil(totalRounds * calorieMultiplier),
|
||||
musicVibe: program.musicVibe,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* TabataFit Workout Data
|
||||
* 50 workouts across 5 categories, 4 durations, 3 levels, 5 trainers
|
||||
* 50 workouts across 5 categories, 4 durations, 3 levels, 2 trainers
|
||||
*/
|
||||
|
||||
import { Workout } from '../types'
|
||||
@@ -12,7 +12,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Full Body Ignite',
|
||||
trainerId: 'emma',
|
||||
trainerId: 'felia',
|
||||
category: 'full-body',
|
||||
level: 'Beginner',
|
||||
duration: 4,
|
||||
@@ -35,7 +35,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '2',
|
||||
title: 'Total Body Blast',
|
||||
trainerId: 'jake',
|
||||
trainerId: 'felix',
|
||||
category: 'full-body',
|
||||
level: 'Intermediate',
|
||||
duration: 8,
|
||||
@@ -57,7 +57,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '3',
|
||||
title: 'Power Surge',
|
||||
trainerId: 'alex',
|
||||
trainerId: 'felix',
|
||||
category: 'full-body',
|
||||
level: 'Advanced',
|
||||
duration: 12,
|
||||
@@ -78,7 +78,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '4',
|
||||
title: 'Morning Wake-Up',
|
||||
trainerId: 'emma',
|
||||
trainerId: 'felia',
|
||||
category: 'full-body',
|
||||
level: 'Beginner',
|
||||
duration: 4,
|
||||
@@ -99,7 +99,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '5',
|
||||
title: 'Endurance Builder',
|
||||
trainerId: 'alex',
|
||||
trainerId: 'felix',
|
||||
category: 'full-body',
|
||||
level: 'Advanced',
|
||||
duration: 20,
|
||||
@@ -120,7 +120,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '6',
|
||||
title: 'Quick Burn',
|
||||
trainerId: 'emma',
|
||||
trainerId: 'felia',
|
||||
category: 'full-body',
|
||||
level: 'Beginner',
|
||||
duration: 4,
|
||||
@@ -141,7 +141,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '7',
|
||||
title: 'Functional Flow',
|
||||
trainerId: 'sofia',
|
||||
trainerId: 'felia',
|
||||
category: 'full-body',
|
||||
level: 'Intermediate',
|
||||
duration: 8,
|
||||
@@ -162,7 +162,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '8',
|
||||
title: 'Athletic Power',
|
||||
trainerId: 'jake',
|
||||
trainerId: 'felix',
|
||||
category: 'full-body',
|
||||
level: 'Advanced',
|
||||
duration: 12,
|
||||
@@ -183,7 +183,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '9',
|
||||
title: 'Sweat Session',
|
||||
trainerId: 'mia',
|
||||
trainerId: 'felia',
|
||||
category: 'full-body',
|
||||
level: 'Intermediate',
|
||||
duration: 8,
|
||||
@@ -204,7 +204,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '10',
|
||||
title: 'Total Tone',
|
||||
trainerId: 'sofia',
|
||||
trainerId: 'felia',
|
||||
category: 'full-body',
|
||||
level: 'Beginner',
|
||||
duration: 4,
|
||||
@@ -229,7 +229,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '11',
|
||||
title: 'Core Crusher',
|
||||
trainerId: 'mia',
|
||||
trainerId: 'felia',
|
||||
category: 'core',
|
||||
level: 'Intermediate',
|
||||
duration: 4,
|
||||
@@ -251,7 +251,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '12',
|
||||
title: 'Ab Shredder',
|
||||
trainerId: 'mia',
|
||||
trainerId: 'felia',
|
||||
category: 'core',
|
||||
level: 'Advanced',
|
||||
duration: 8,
|
||||
@@ -272,7 +272,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '13',
|
||||
title: 'Core Foundations',
|
||||
trainerId: 'emma',
|
||||
trainerId: 'felia',
|
||||
category: 'core',
|
||||
level: 'Beginner',
|
||||
duration: 4,
|
||||
@@ -293,7 +293,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '14',
|
||||
title: 'Oblique Inferno',
|
||||
trainerId: 'jake',
|
||||
trainerId: 'felix',
|
||||
category: 'core',
|
||||
level: 'Intermediate',
|
||||
duration: 8,
|
||||
@@ -314,7 +314,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '15',
|
||||
title: 'Core Endurance',
|
||||
trainerId: 'mia',
|
||||
trainerId: 'felia',
|
||||
category: 'core',
|
||||
level: 'Advanced',
|
||||
duration: 12,
|
||||
@@ -335,7 +335,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '16',
|
||||
title: 'Gentle Core',
|
||||
trainerId: 'sofia',
|
||||
trainerId: 'felia',
|
||||
category: 'core',
|
||||
level: 'Beginner',
|
||||
duration: 4,
|
||||
@@ -356,7 +356,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '17',
|
||||
title: 'Core Power',
|
||||
trainerId: 'alex',
|
||||
trainerId: 'felix',
|
||||
category: 'core',
|
||||
level: 'Intermediate',
|
||||
duration: 4,
|
||||
@@ -377,7 +377,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '18',
|
||||
title: '360 Core',
|
||||
trainerId: 'jake',
|
||||
trainerId: 'felix',
|
||||
category: 'core',
|
||||
level: 'Advanced',
|
||||
duration: 8,
|
||||
@@ -398,7 +398,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '19',
|
||||
title: 'Core Stability',
|
||||
trainerId: 'sofia',
|
||||
trainerId: 'felia',
|
||||
category: 'core',
|
||||
level: 'Beginner',
|
||||
duration: 4,
|
||||
@@ -419,7 +419,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '20',
|
||||
title: 'Core Marathon',
|
||||
trainerId: 'mia',
|
||||
trainerId: 'felia',
|
||||
category: 'core',
|
||||
level: 'Advanced',
|
||||
duration: 20,
|
||||
@@ -444,7 +444,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '21',
|
||||
title: 'Upper Body Blitz',
|
||||
trainerId: 'jake',
|
||||
trainerId: 'felix',
|
||||
category: 'upper-body',
|
||||
level: 'Intermediate',
|
||||
duration: 8,
|
||||
@@ -465,7 +465,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '22',
|
||||
title: 'Arm Sculptor',
|
||||
trainerId: 'jake',
|
||||
trainerId: 'felix',
|
||||
category: 'upper-body',
|
||||
level: 'Beginner',
|
||||
duration: 4,
|
||||
@@ -486,7 +486,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '23',
|
||||
title: 'Push-Up Mastery',
|
||||
trainerId: 'emma',
|
||||
trainerId: 'felia',
|
||||
category: 'upper-body',
|
||||
level: 'Intermediate',
|
||||
duration: 4,
|
||||
@@ -507,7 +507,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '24',
|
||||
title: 'Shoulder Shredder',
|
||||
trainerId: 'jake',
|
||||
trainerId: 'felix',
|
||||
category: 'upper-body',
|
||||
level: 'Advanced',
|
||||
duration: 8,
|
||||
@@ -528,7 +528,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '25',
|
||||
title: 'Chest & Back',
|
||||
trainerId: 'alex',
|
||||
trainerId: 'felix',
|
||||
category: 'upper-body',
|
||||
level: 'Intermediate',
|
||||
duration: 12,
|
||||
@@ -549,7 +549,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '26',
|
||||
title: 'Bodyweight Upper',
|
||||
trainerId: 'emma',
|
||||
trainerId: 'felia',
|
||||
category: 'upper-body',
|
||||
level: 'Beginner',
|
||||
duration: 4,
|
||||
@@ -570,7 +570,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '27',
|
||||
title: 'Strength Tabata',
|
||||
trainerId: 'jake',
|
||||
trainerId: 'felix',
|
||||
category: 'upper-body',
|
||||
level: 'Advanced',
|
||||
duration: 12,
|
||||
@@ -591,7 +591,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '28',
|
||||
title: 'Tone & Define',
|
||||
trainerId: 'sofia',
|
||||
trainerId: 'felia',
|
||||
category: 'upper-body',
|
||||
level: 'Beginner',
|
||||
duration: 4,
|
||||
@@ -612,7 +612,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '29',
|
||||
title: 'Power Arms',
|
||||
trainerId: 'alex',
|
||||
trainerId: 'felix',
|
||||
category: 'upper-body',
|
||||
level: 'Intermediate',
|
||||
duration: 8,
|
||||
@@ -633,7 +633,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '30',
|
||||
title: 'Upper Endurance',
|
||||
trainerId: 'jake',
|
||||
trainerId: 'felix',
|
||||
category: 'upper-body',
|
||||
level: 'Advanced',
|
||||
duration: 20,
|
||||
@@ -658,7 +658,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '31',
|
||||
title: 'Lower Body Burn',
|
||||
trainerId: 'emma',
|
||||
trainerId: 'felia',
|
||||
category: 'lower-body',
|
||||
level: 'Beginner',
|
||||
duration: 4,
|
||||
@@ -679,7 +679,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '32',
|
||||
title: 'Leg Day Tabata',
|
||||
trainerId: 'jake',
|
||||
trainerId: 'felix',
|
||||
category: 'lower-body',
|
||||
level: 'Intermediate',
|
||||
duration: 8,
|
||||
@@ -700,7 +700,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '33',
|
||||
title: 'Glute Activator',
|
||||
trainerId: 'mia',
|
||||
trainerId: 'felia',
|
||||
category: 'lower-body',
|
||||
level: 'Beginner',
|
||||
duration: 4,
|
||||
@@ -721,7 +721,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '34',
|
||||
title: 'Explosive Legs',
|
||||
trainerId: 'alex',
|
||||
trainerId: 'felix',
|
||||
category: 'lower-body',
|
||||
level: 'Advanced',
|
||||
duration: 8,
|
||||
@@ -742,7 +742,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '35',
|
||||
title: 'Squat Challenge',
|
||||
trainerId: 'jake',
|
||||
trainerId: 'felix',
|
||||
category: 'lower-body',
|
||||
level: 'Intermediate',
|
||||
duration: 4,
|
||||
@@ -763,7 +763,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '36',
|
||||
title: 'Lower Power',
|
||||
trainerId: 'jake',
|
||||
trainerId: 'felix',
|
||||
category: 'lower-body',
|
||||
level: 'Advanced',
|
||||
duration: 12,
|
||||
@@ -784,7 +784,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '37',
|
||||
title: 'Knee-Friendly Legs',
|
||||
trainerId: 'sofia',
|
||||
trainerId: 'felia',
|
||||
category: 'lower-body',
|
||||
level: 'Beginner',
|
||||
duration: 4,
|
||||
@@ -805,7 +805,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '38',
|
||||
title: 'Sprint Tabata',
|
||||
trainerId: 'alex',
|
||||
trainerId: 'felix',
|
||||
category: 'lower-body',
|
||||
level: 'Advanced',
|
||||
duration: 4,
|
||||
@@ -826,7 +826,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '39',
|
||||
title: 'Legs & Glutes',
|
||||
trainerId: 'mia',
|
||||
trainerId: 'felia',
|
||||
category: 'lower-body',
|
||||
level: 'Intermediate',
|
||||
duration: 12,
|
||||
@@ -847,7 +847,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '40',
|
||||
title: 'Leg Day Marathon',
|
||||
trainerId: 'jake',
|
||||
trainerId: 'felix',
|
||||
category: 'lower-body',
|
||||
level: 'Advanced',
|
||||
duration: 20,
|
||||
@@ -872,7 +872,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '41',
|
||||
title: 'HIIT Extreme',
|
||||
trainerId: 'alex',
|
||||
trainerId: 'felix',
|
||||
category: 'cardio',
|
||||
level: 'Advanced',
|
||||
duration: 8,
|
||||
@@ -894,7 +894,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '42',
|
||||
title: 'Cardio Blast',
|
||||
trainerId: 'alex',
|
||||
trainerId: 'felix',
|
||||
category: 'cardio',
|
||||
level: 'Intermediate',
|
||||
duration: 4,
|
||||
@@ -915,7 +915,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '43',
|
||||
title: 'Dance Cardio',
|
||||
trainerId: 'emma',
|
||||
trainerId: 'felia',
|
||||
category: 'cardio',
|
||||
level: 'Beginner',
|
||||
duration: 4,
|
||||
@@ -937,7 +937,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '44',
|
||||
title: 'Fat Burn Express',
|
||||
trainerId: 'alex',
|
||||
trainerId: 'felix',
|
||||
category: 'cardio',
|
||||
level: 'Intermediate',
|
||||
duration: 8,
|
||||
@@ -958,7 +958,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '45',
|
||||
title: 'Low Impact Cardio',
|
||||
trainerId: 'sofia',
|
||||
trainerId: 'felia',
|
||||
category: 'cardio',
|
||||
level: 'Beginner',
|
||||
duration: 4,
|
||||
@@ -979,7 +979,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '46',
|
||||
title: 'Cardio Inferno',
|
||||
trainerId: 'alex',
|
||||
trainerId: 'felix',
|
||||
category: 'cardio',
|
||||
level: 'Advanced',
|
||||
duration: 12,
|
||||
@@ -1000,7 +1000,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '47',
|
||||
title: 'Sunrise Flow',
|
||||
trainerId: 'sofia',
|
||||
trainerId: 'felia',
|
||||
category: 'cardio',
|
||||
level: 'Beginner',
|
||||
duration: 4,
|
||||
@@ -1021,7 +1021,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '48',
|
||||
title: 'Power Hour',
|
||||
trainerId: 'alex',
|
||||
trainerId: 'felix',
|
||||
category: 'cardio',
|
||||
level: 'Intermediate',
|
||||
duration: 12,
|
||||
@@ -1042,7 +1042,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '49',
|
||||
title: 'Deep Stretch',
|
||||
trainerId: 'sofia',
|
||||
trainerId: 'felia',
|
||||
category: 'cardio',
|
||||
level: 'Beginner',
|
||||
duration: 8,
|
||||
@@ -1063,7 +1063,7 @@ export const WORKOUTS: Workout[] = [
|
||||
{
|
||||
id: '50',
|
||||
title: 'Cardio Marathon',
|
||||
trainerId: 'alex',
|
||||
trainerId: 'felix',
|
||||
category: 'cardio',
|
||||
level: 'Advanced',
|
||||
duration: 20,
|
||||
|
||||
@@ -3,14 +3,24 @@
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Feb 20, 2026
|
||||
### Apr 10, 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 |
|
||||
| #5375 | 8:38 PM | 🟣 | RevenueCat Entitlement Check Fixed with Proper API Usage | ~261 |
|
||||
| #5355 | 7:48 PM | 🟣 | usePurchases Hook Exported from Shared Hooks | ~121 |
|
||||
| #5354 | 7:47 PM | 🟣 | RevenueCat Hook for Subscription Management | ~281 |
|
||||
| #5317 | 2:59 PM | 🔵 | Notification hook implementation examined | ~362 |
|
||||
| #6008 | 10:03 AM | 🔵 | Kine Timer Hook with Multi-Block State Machine | ~409 |
|
||||
|
||||
### Apr 13, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6304 | 11:46 PM | ✅ | Timer final second delay increased to 600ms | ~215 |
|
||||
| #6303 | 11:41 PM | 🔴 | TypeScript verification passed for timer threshold changes | ~173 |
|
||||
| #6296 | 11:37 PM | 🔴 | Timer transitions at 1 second display improved with 250ms delay | ~263 |
|
||||
| #6291 | 11:25 PM | 🔴 | Timer phase transition threshold adjusted | ~229 |
|
||||
|
||||
### Apr 17, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6382 | 10:35 AM | 🔵 | Investigated Premium Access Control Logic in usePurchases Hook | ~226 |
|
||||
</claude-mem-context>
|
||||
@@ -4,6 +4,8 @@
|
||||
* Loads tracks from Supabase Storage based on workout's musicVibe
|
||||
*/
|
||||
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
import { useRef, useEffect, useCallback, useState } from 'react'
|
||||
import { Audio, type AVPlaybackStatus } from 'expo-av'
|
||||
import { useUserStore } from '../stores'
|
||||
@@ -118,23 +120,24 @@ export function useMusicPlayer(options: UseMusicPlayerOptions): UseMusicPlayerRe
|
||||
|
||||
// For mock tracks without URLs, skip loading
|
||||
if (!track.url) {
|
||||
console.log(`[MusicPlayer] Mock track: ${track.title} - ${track.artist}`)
|
||||
logger.log(`[MusicPlayer] Mock track: ${track.title} - ${track.artist}`)
|
||||
return
|
||||
}
|
||||
|
||||
const { sound } = await Audio.Sound.createAsync(
|
||||
{ uri: track.url },
|
||||
{
|
||||
{
|
||||
shouldPlay: autoPlay && isPlaying && musicEnabled,
|
||||
volume: volume,
|
||||
isLooping: false,
|
||||
positionMillis: 10_000,
|
||||
},
|
||||
onPlaybackStatusUpdate
|
||||
)
|
||||
|
||||
soundRef.current = sound
|
||||
} catch (err) {
|
||||
console.error('[MusicPlayer] Error loading track:', err)
|
||||
logger.error('[MusicPlayer] Error loading track:', err)
|
||||
}
|
||||
}, [isPlaying, musicEnabled, volume])
|
||||
|
||||
@@ -174,7 +177,7 @@ export function useMusicPlayer(options: UseMusicPlayerOptions): UseMusicPlayerRe
|
||||
await soundRef.current.pauseAsync()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MusicPlayer] Error updating playback:', err)
|
||||
logger.error('[MusicPlayer] Error updating playback:', err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +195,7 @@ export function useMusicPlayer(options: UseMusicPlayerOptions): UseMusicPlayerRe
|
||||
await soundRef.current.setVolumeAsync(volume)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MusicPlayer] Error updating volume:', err)
|
||||
logger.error('[MusicPlayer] Error updating volume:', err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +214,7 @@ export function useMusicPlayer(options: UseMusicPlayerOptions): UseMusicPlayerRe
|
||||
await soundRef.current.playAsync()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MusicPlayer] Error toggling music:', err)
|
||||
logger.error('[MusicPlayer] Error toggling music:', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
* shows an Alert offering to simulate the purchase for testing.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Alert } from 'react-native'
|
||||
import { logger } from '../utils/logger'
|
||||
import Purchases, {
|
||||
CustomerInfo,
|
||||
PurchasesOfferings,
|
||||
@@ -87,13 +88,17 @@ export function usePurchases(): UsePurchasesReturn {
|
||||
|
||||
// Only update if different
|
||||
if (subscription !== newPlan) {
|
||||
console.log('[Purchases] Syncing subscription to store:', newPlan)
|
||||
logger.log('[Purchases] Syncing subscription to store:', newPlan)
|
||||
setSubscription(newPlan)
|
||||
}
|
||||
},
|
||||
[subscription, setSubscription]
|
||||
)
|
||||
|
||||
// Keep a stable ref so the mount-only effect can call the latest version
|
||||
const syncRef = useRef(syncSubscriptionToStore)
|
||||
syncRef.current = syncSubscriptionToStore
|
||||
|
||||
// Fetch offerings and customer info on mount
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
@@ -105,9 +110,9 @@ export function usePurchases(): UsePurchasesReturn {
|
||||
|
||||
setOfferings(offeringsResult)
|
||||
setCustomerInfo(customerInfoResult)
|
||||
syncSubscriptionToStore(customerInfoResult)
|
||||
syncRef.current(customerInfoResult)
|
||||
|
||||
console.log('[Purchases] Offerings loaded:', {
|
||||
logger.log('[Purchases] Offerings loaded:', {
|
||||
hasMonthly: !!offeringsResult.current?.monthly,
|
||||
hasAnnual: !!offeringsResult.current?.annual,
|
||||
monthlyProductId: offeringsResult.current?.monthly?.product.identifier,
|
||||
@@ -116,21 +121,21 @@ export function usePurchases(): UsePurchasesReturn {
|
||||
annualPrice: offeringsResult.current?.annual?.product.priceString,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Purchases] Failed to fetch offerings/customerInfo:', error)
|
||||
logger.error('[Purchases] Failed to fetch offerings/customerInfo:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [syncSubscriptionToStore])
|
||||
}, []) // empty deps — only runs once per mount
|
||||
|
||||
// Listen for customer info changes (renewals, expirations, etc.)
|
||||
useEffect(() => {
|
||||
const listener = (info: CustomerInfo) => {
|
||||
console.log('[Purchases] Customer info updated')
|
||||
logger.log('[Purchases] Customer info updated')
|
||||
setCustomerInfo(info)
|
||||
syncSubscriptionToStore(info)
|
||||
syncRef.current(info)
|
||||
}
|
||||
|
||||
Purchases.addCustomerInfoUpdateListener(listener)
|
||||
@@ -138,40 +143,41 @@ export function usePurchases(): UsePurchasesReturn {
|
||||
return () => {
|
||||
Purchases.removeCustomerInfoUpdateListener(listener)
|
||||
}
|
||||
}, [syncSubscriptionToStore])
|
||||
}, [])
|
||||
|
||||
// Purchase a package
|
||||
const purchasePackage = useCallback(
|
||||
async (pkg: PurchasesPackage): Promise<PurchaseResult> => {
|
||||
try {
|
||||
console.log('[Purchases] Starting purchase for:', pkg.identifier, pkg.product.identifier)
|
||||
logger.log('[Purchases] Starting purchase for:', pkg.identifier, pkg.product.identifier)
|
||||
const { customerInfo: newInfo } = await Purchases.purchasePackage(pkg)
|
||||
|
||||
setCustomerInfo(newInfo)
|
||||
syncSubscriptionToStore(newInfo)
|
||||
|
||||
console.log('[Purchases] Active entitlements:', Object.keys(newInfo.entitlements.active))
|
||||
console.log('[Purchases] All entitlements:', Object.keys(newInfo.entitlements.all))
|
||||
console.log('[Purchases] Looking for entitlement:', ENTITLEMENT_ID)
|
||||
logger.log('[Purchases] Active entitlements:', Object.keys(newInfo.entitlements.active))
|
||||
logger.log('[Purchases] All entitlements:', Object.keys(newInfo.entitlements.all))
|
||||
logger.log('[Purchases] Looking for entitlement:', ENTITLEMENT_ID)
|
||||
const success = hasPremiumEntitlement(newInfo)
|
||||
console.log('[Purchases] Purchase result:', { success })
|
||||
logger.log('[Purchases] Purchase result:', { success })
|
||||
|
||||
return { success, cancelled: false }
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const purchaseError = error as { userCancelled?: boolean; message?: string }
|
||||
// Handle user cancellation
|
||||
if (error.userCancelled) {
|
||||
console.log('[Purchases] Purchase cancelled by user')
|
||||
if (purchaseError.userCancelled) {
|
||||
logger.log('[Purchases] Purchase cancelled by user')
|
||||
return { success: false, cancelled: true }
|
||||
}
|
||||
|
||||
console.error('[Purchases] Purchase error:', error)
|
||||
logger.error('[Purchases] Purchase error:', error)
|
||||
|
||||
// DEV mode: offer to simulate the purchase when StoreKit fails
|
||||
if (__DEV__) {
|
||||
return new Promise((resolve) => {
|
||||
Alert.alert(
|
||||
'Purchase failed (DEV)',
|
||||
`StoreKit error: ${error.message || 'Unknown error'}\n\nProduct: ${pkg.product.identifier}\n\nSimulate a successful purchase for testing?`,
|
||||
`StoreKit error: ${purchaseError.message || 'Unknown error'}\n\nProduct: ${pkg.product.identifier}\n\nSimulate a successful purchase for testing?`,
|
||||
[
|
||||
{
|
||||
text: 'Cancel',
|
||||
@@ -221,7 +227,7 @@ export function usePurchases(): UsePurchasesReturn {
|
||||
setCustomerInfo(mockCustomerInfo)
|
||||
syncSubscriptionToStore(mockCustomerInfo)
|
||||
|
||||
console.log('[Purchases] DEV: Simulated purchase →', plan)
|
||||
logger.log('[Purchases] DEV: Simulated purchase →', plan)
|
||||
resolve({ success: true, cancelled: false })
|
||||
},
|
||||
},
|
||||
@@ -233,7 +239,7 @@ export function usePurchases(): UsePurchasesReturn {
|
||||
return {
|
||||
success: false,
|
||||
cancelled: false,
|
||||
error: error.message || 'Purchase failed',
|
||||
error: purchaseError.message || 'Purchase failed',
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -243,18 +249,18 @@ export function usePurchases(): UsePurchasesReturn {
|
||||
// Restore purchases
|
||||
const restorePurchases = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
console.log('[Purchases] Restoring purchases...')
|
||||
logger.log('[Purchases] Restoring purchases...')
|
||||
const restoredInfo = await Purchases.restorePurchases()
|
||||
|
||||
setCustomerInfo(restoredInfo)
|
||||
syncSubscriptionToStore(restoredInfo)
|
||||
|
||||
const hasPremium = hasPremiumEntitlement(restoredInfo)
|
||||
console.log('[Purchases] Restore result:', { hasPremium })
|
||||
logger.log('[Purchases] Restore result:', { hasPremium })
|
||||
|
||||
return hasPremium
|
||||
} catch (error) {
|
||||
console.error('[Purchases] Restore failed:', error)
|
||||
logger.error('[Purchases] Restore failed:', error)
|
||||
|
||||
// DEV mode: offer to simulate restore
|
||||
if (__DEV__) {
|
||||
@@ -301,7 +307,7 @@ export function usePurchases(): UsePurchasesReturn {
|
||||
setCustomerInfo(mockCustomerInfo)
|
||||
syncSubscriptionToStore(mockCustomerInfo)
|
||||
|
||||
console.log('[Purchases] DEV: Simulated restore → premium-yearly')
|
||||
logger.log('[Purchases] DEV: Simulated restore → premium-yearly')
|
||||
resolve(true)
|
||||
},
|
||||
},
|
||||
|
||||
391
src/shared/hooks/useTabataTimer.ts
Normal file
391
src/shared/hooks/useTabataTimer.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* Tabata Timer Hook
|
||||
* Multi-block timer supporting warmup, tabata blocks, inter-block rest, and cooldown
|
||||
*
|
||||
* State machine:
|
||||
* WARMUP → BLOCK(WORK↔REST × rounds) → INTER_BLOCK_REST → next block → COOLDOWN → COMPLETE
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { create } from 'zustand'
|
||||
import type { TabataSession, TabataExercise, TabataTimerPhase, TabataBlock, TimedMovement } from '../types/program'
|
||||
|
||||
// ─── Tabata Player Store ─────────────────────────────────────────
|
||||
|
||||
interface TabataPlayerState {
|
||||
session: TabataSession | null
|
||||
phase: TabataTimerPhase
|
||||
timeRemaining: number
|
||||
currentBlockIndex: number
|
||||
currentRound: number
|
||||
currentWarmupIndex: number
|
||||
currentCooldownIndex: number
|
||||
isPaused: boolean
|
||||
isRunning: boolean
|
||||
calories: number
|
||||
startedAt: number | null
|
||||
|
||||
loadSession: (session: TabataSession) => void
|
||||
setPhase: (phase: TabataTimerPhase) => void
|
||||
setTimeRemaining: (time: number) => void
|
||||
setCurrentBlock: (index: number) => void
|
||||
setCurrentRound: (round: number) => void
|
||||
setWarmupIndex: (index: number) => void
|
||||
setCooldownIndex: (index: number) => void
|
||||
setPaused: (paused: boolean) => void
|
||||
setRunning: (running: boolean) => void
|
||||
addCalories: (amount: number) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
const INITIAL_STATE = {
|
||||
session: null as TabataSession | null,
|
||||
phase: 'WARMUP' as TabataTimerPhase,
|
||||
timeRemaining: 0,
|
||||
currentBlockIndex: 0,
|
||||
currentRound: 1,
|
||||
currentWarmupIndex: 0,
|
||||
currentCooldownIndex: 0,
|
||||
isPaused: false,
|
||||
isRunning: false,
|
||||
calories: 0,
|
||||
startedAt: null as number | null,
|
||||
}
|
||||
|
||||
export const useTabataPlayerStore = create<TabataPlayerState>((set) => ({
|
||||
...INITIAL_STATE,
|
||||
|
||||
loadSession: (session) =>
|
||||
set({
|
||||
session,
|
||||
phase: 'WARMUP',
|
||||
timeRemaining: session.warmup.movements[0]?.duration ?? 0,
|
||||
currentBlockIndex: 0,
|
||||
currentRound: 1,
|
||||
currentWarmupIndex: 0,
|
||||
currentCooldownIndex: 0,
|
||||
isPaused: false,
|
||||
isRunning: false,
|
||||
calories: 0,
|
||||
startedAt: null,
|
||||
}),
|
||||
|
||||
setPhase: (phase) => set({ phase }),
|
||||
setTimeRemaining: (time) => set({ timeRemaining: time }),
|
||||
setCurrentBlock: (index) => set({ currentBlockIndex: index }),
|
||||
setCurrentRound: (round) => set({ currentRound: round }),
|
||||
setWarmupIndex: (index) => set({ currentWarmupIndex: index }),
|
||||
setCooldownIndex: (index) => set({ currentCooldownIndex: index }),
|
||||
setPaused: (paused) => set({ isPaused: paused }),
|
||||
setRunning: (running) =>
|
||||
set((state) => ({
|
||||
isRunning: running,
|
||||
startedAt: running && !state.startedAt ? Date.now() : state.startedAt,
|
||||
})),
|
||||
addCalories: (amount) => set((state) => ({ calories: state.calories + amount })),
|
||||
reset: () => set(INITIAL_STATE),
|
||||
}))
|
||||
|
||||
// ─── Hook Return Type ──────────────────────────────────────────
|
||||
|
||||
export interface UseTabataTimerReturn {
|
||||
phase: TabataTimerPhase
|
||||
timeRemaining: number
|
||||
currentRound: number
|
||||
totalRounds: number
|
||||
currentBlockIndex: number
|
||||
totalBlocks: number
|
||||
currentExercise: TabataExercise | null
|
||||
nextExercise: TabataExercise | null
|
||||
currentConseil: string
|
||||
isOddRound: boolean
|
||||
progress: number
|
||||
isPaused: boolean
|
||||
isRunning: boolean
|
||||
isComplete: boolean
|
||||
calories: number
|
||||
currentWarmupMovement: TimedMovement | null
|
||||
currentCooldownMovement: TimedMovement | null
|
||||
start: () => void
|
||||
pause: () => void
|
||||
resume: () => void
|
||||
skip: () => void
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
// ─── Constants ─────────────────────────────────────────────────
|
||||
|
||||
const INTER_BLOCK_REST_SECONDS = 60
|
||||
|
||||
// ─── Hook ──────────────────────────────────────────────────────
|
||||
|
||||
export function useTabataTimer(session: TabataSession | null): UseTabataTimerReturn {
|
||||
const store = useTabataPlayerStore()
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// Load session on mount
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
store.loadSession(session)
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
}
|
||||
}, [session?.id])
|
||||
|
||||
const s = session
|
||||
const currentBlock: TabataBlock | null = s?.blocks[store.currentBlockIndex] ?? null
|
||||
|
||||
// ─── Computed values ─────────────────────────────────────────
|
||||
|
||||
const isOddRound = store.currentRound % 2 === 1
|
||||
|
||||
const currentExercise: TabataExercise | null = useMemo(() => {
|
||||
if (!currentBlock) return null
|
||||
return isOddRound ? currentBlock.oddExercise : currentBlock.evenExercise
|
||||
}, [currentBlock, isOddRound])
|
||||
|
||||
const currentConseil = currentExercise?.conseil ?? ''
|
||||
|
||||
const nextExercise: TabataExercise | null = useMemo(() => {
|
||||
if (!currentBlock) return null
|
||||
const nextOdd = !isOddRound
|
||||
return nextOdd ? currentBlock.oddExercise : currentBlock.evenExercise
|
||||
}, [currentBlock, isOddRound])
|
||||
|
||||
const totalRounds = s?.totalRounds ?? 0
|
||||
|
||||
const progress = useMemo(() => {
|
||||
if (!s || store.phase === 'COMPLETE') return 1
|
||||
if (store.phase === 'WARMUP' || store.phase === 'COOLDOWN') return 0
|
||||
|
||||
// Progress across all blocks
|
||||
const completedBlockRounds = store.currentBlockIndex * (currentBlock?.rounds ?? 8)
|
||||
const total = s.totalRounds
|
||||
if (total === 0) return 0
|
||||
return (completedBlockRounds + store.currentRound - 1) / total
|
||||
}, [s, store.phase, store.currentBlockIndex, store.currentRound, currentBlock])
|
||||
|
||||
const currentWarmupMovement: TimedMovement | null =
|
||||
s?.warmup.movements[store.currentWarmupIndex] ?? null
|
||||
|
||||
const currentCooldownMovement: TimedMovement | null =
|
||||
s?.cooldown.movements[store.currentCooldownIndex] ?? null
|
||||
|
||||
// ─── Phase durations ─────────────────────────────────────────
|
||||
|
||||
const phaseDuration = useMemo(() => {
|
||||
switch (store.phase) {
|
||||
case 'WARMUP':
|
||||
return currentWarmupMovement?.duration ?? 0
|
||||
case 'WORK':
|
||||
return currentBlock?.workTime ?? 20
|
||||
case 'REST':
|
||||
return currentBlock?.restTime ?? 10
|
||||
case 'INTER_BLOCK_REST':
|
||||
return INTER_BLOCK_REST_SECONDS
|
||||
case 'COOLDOWN':
|
||||
return currentCooldownMovement?.duration ?? 0
|
||||
case 'COMPLETE':
|
||||
return 0
|
||||
}
|
||||
}, [store.phase, currentBlock, currentWarmupMovement, currentCooldownMovement])
|
||||
|
||||
// ─── Timer tick ──────────────────────────────────────────────
|
||||
// Uses recursive setTimeout so the last second (showing "1")
|
||||
// only holds for 600ms before transitioning, instead of the full 1000ms.
|
||||
|
||||
useEffect(() => {
|
||||
if (!store.isRunning || store.isPaused || store.phase === 'COMPLETE' || !s) {
|
||||
if (intervalRef.current) {
|
||||
clearTimeout(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
function scheduleTick() {
|
||||
const state = useTabataPlayerStore.getState()
|
||||
const isLastSecond = state.timeRemaining <= 1.9
|
||||
const delay = isLastSecond ? 600 : 1000
|
||||
|
||||
intervalRef.current = setTimeout(() => {
|
||||
const curState = useTabataPlayerStore.getState()
|
||||
if (!curState.isRunning || curState.isPaused || curState.phase === 'COMPLETE' || !s) return
|
||||
|
||||
if (curState.timeRemaining <= 1.9) {
|
||||
transitionPhase(curState, s)
|
||||
// Don't reschedule — phase change triggers effect re-run
|
||||
} else {
|
||||
useTabataPlayerStore.getState().setTimeRemaining(curState.timeRemaining - 1)
|
||||
scheduleTick()
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
scheduleTick()
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearTimeout(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
}, [store.isRunning, store.isPaused, store.phase, s?.id])
|
||||
|
||||
// ─── Phase transitions ───────────────────────────────────────
|
||||
|
||||
function transitionPhase(state: TabataPlayerState, session: TabataSession) {
|
||||
const { phase, currentBlockIndex, currentRound } = state
|
||||
const block = session.blocks[currentBlockIndex]
|
||||
|
||||
switch (phase) {
|
||||
case 'WARMUP': {
|
||||
const nextIdx = state.currentWarmupIndex + 1
|
||||
if (nextIdx < session.warmup.movements.length) {
|
||||
// Next warmup movement
|
||||
useTabataPlayerStore.getState().setWarmupIndex(nextIdx)
|
||||
useTabataPlayerStore.getState().setTimeRemaining(session.warmup.movements[nextIdx].duration)
|
||||
} else {
|
||||
// Warmup done → start first block
|
||||
useTabataPlayerStore.getState().setPhase('WORK')
|
||||
useTabataPlayerStore.getState().setTimeRemaining(block?.workTime ?? 20)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'WORK': {
|
||||
// Add calories
|
||||
const caloriesPerRound = session.calories / session.totalRounds
|
||||
useTabataPlayerStore.getState().addCalories(Math.round(caloriesPerRound))
|
||||
|
||||
useTabataPlayerStore.getState().setPhase('REST')
|
||||
useTabataPlayerStore.getState().setTimeRemaining(block?.restTime ?? 10)
|
||||
break
|
||||
}
|
||||
|
||||
case 'REST': {
|
||||
const blockRounds = block?.rounds ?? 8
|
||||
if (currentRound >= blockRounds) {
|
||||
// Block complete
|
||||
const nextBlockIdx = currentBlockIndex + 1
|
||||
if (nextBlockIdx < session.blocks.length) {
|
||||
// Inter-block rest
|
||||
useTabataPlayerStore.getState().setPhase('INTER_BLOCK_REST')
|
||||
useTabataPlayerStore.getState().setTimeRemaining(INTER_BLOCK_REST_SECONDS)
|
||||
useTabataPlayerStore.getState().setCurrentBlock(nextBlockIdx)
|
||||
useTabataPlayerStore.getState().setCurrentRound(1)
|
||||
} else {
|
||||
// All blocks done → cooldown
|
||||
useTabataPlayerStore.getState().setPhase('COOLDOWN')
|
||||
useTabataPlayerStore.getState().setTimeRemaining(session.cooldown.movements[0]?.duration ?? 0)
|
||||
useTabataPlayerStore.getState().setCooldownIndex(0)
|
||||
}
|
||||
} else {
|
||||
// Next round in same block
|
||||
useTabataPlayerStore.getState().setPhase('WORK')
|
||||
useTabataPlayerStore.getState().setTimeRemaining(block?.workTime ?? 20)
|
||||
useTabataPlayerStore.getState().setCurrentRound(currentRound + 1)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTER_BLOCK_REST': {
|
||||
useTabataPlayerStore.getState().setPhase('WORK')
|
||||
useTabataPlayerStore.getState().setTimeRemaining(
|
||||
session.blocks[currentBlockIndex]?.workTime ?? 20
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case 'COOLDOWN': {
|
||||
const nextIdx = state.currentCooldownIndex + 1
|
||||
if (nextIdx < session.cooldown.movements.length) {
|
||||
useTabataPlayerStore.getState().setCooldownIndex(nextIdx)
|
||||
useTabataPlayerStore.getState().setTimeRemaining(session.cooldown.movements[nextIdx].duration)
|
||||
} else {
|
||||
// Complete
|
||||
useTabataPlayerStore.getState().setPhase('COMPLETE')
|
||||
useTabataPlayerStore.getState().setTimeRemaining(0)
|
||||
useTabataPlayerStore.getState().setRunning(false)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Controls ────────────────────────────────────────────────
|
||||
|
||||
const start = useCallback(() => {
|
||||
store.setRunning(true)
|
||||
store.setPaused(false)
|
||||
}, [])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
store.setPaused(true)
|
||||
}, [])
|
||||
|
||||
const resume = useCallback(() => {
|
||||
store.setPaused(false)
|
||||
}, [])
|
||||
|
||||
const skip = useCallback(() => {
|
||||
const state = useTabataPlayerStore.getState()
|
||||
if (!s) return
|
||||
|
||||
switch (state.phase) {
|
||||
case 'WARMUP':
|
||||
// Skip to first block WORK
|
||||
useTabataPlayerStore.getState().setPhase('WORK')
|
||||
useTabataPlayerStore.getState().setTimeRemaining(s.blocks[0]?.workTime ?? 20)
|
||||
break
|
||||
case 'WORK':
|
||||
useTabataPlayerStore.getState().setPhase('REST')
|
||||
useTabataPlayerStore.getState().setTimeRemaining(currentBlock?.restTime ?? 10)
|
||||
break
|
||||
case 'REST':
|
||||
transitionPhase(state, s)
|
||||
break
|
||||
case 'INTER_BLOCK_REST':
|
||||
useTabataPlayerStore.getState().setPhase('WORK')
|
||||
useTabataPlayerStore.getState().setTimeRemaining(
|
||||
s.blocks[state.currentBlockIndex]?.workTime ?? 20
|
||||
)
|
||||
break
|
||||
case 'COOLDOWN':
|
||||
useTabataPlayerStore.getState().setPhase('COMPLETE')
|
||||
useTabataPlayerStore.getState().setTimeRemaining(0)
|
||||
useTabataPlayerStore.getState().setRunning(false)
|
||||
break
|
||||
}
|
||||
}, [s, currentBlock])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
useTabataPlayerStore.getState().reset()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
phase: store.phase,
|
||||
timeRemaining: store.timeRemaining,
|
||||
currentRound: store.currentRound,
|
||||
totalRounds,
|
||||
currentBlockIndex: store.currentBlockIndex,
|
||||
totalBlocks: s?.blocks.length ?? 0,
|
||||
currentExercise,
|
||||
nextExercise,
|
||||
currentConseil,
|
||||
isOddRound,
|
||||
progress,
|
||||
isPaused: store.isPaused,
|
||||
isRunning: store.isRunning,
|
||||
isComplete: store.phase === 'COMPLETE',
|
||||
calories: store.calories,
|
||||
currentWarmupMovement,
|
||||
currentCooldownMovement,
|
||||
start,
|
||||
pause,
|
||||
resume,
|
||||
skip,
|
||||
stop,
|
||||
}
|
||||
}
|
||||
@@ -64,49 +64,60 @@ export function useTimer(workout: Workout | null): UseTimerReturn {
|
||||
const currentExercise = w.exercises[exerciseIndex]?.name ?? ''
|
||||
const nextExercise = w.exercises[(exerciseIndex + 1) % w.exercises.length]?.name
|
||||
|
||||
// Timer tick
|
||||
// Timer tick — uses recursive setTimeout so the last second (showing "1")
|
||||
// only holds for 600ms before transitioning, instead of the full 1000ms.
|
||||
useEffect(() => {
|
||||
if (!store.isRunning || store.isPaused || store.phase === 'COMPLETE') {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
clearTimeout(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
const s = usePlayerStore.getState()
|
||||
if (s.timeRemaining <= 0) {
|
||||
// Phase transition
|
||||
if (s.phase === 'PREP') {
|
||||
store.setPhase('WORK')
|
||||
store.setTimeRemaining(w.workTime)
|
||||
} else if (s.phase === 'WORK') {
|
||||
// Add calories for completed work phase
|
||||
const caloriesPerRound = workout ? Math.round(workout.calories / workout.rounds) : 5
|
||||
store.addCalories(caloriesPerRound)
|
||||
function scheduleTick() {
|
||||
const state = usePlayerStore.getState()
|
||||
const isLastSecond = state.timeRemaining <= 1.9
|
||||
const delay = isLastSecond ? 600 : 1000
|
||||
|
||||
store.setPhase('REST')
|
||||
store.setTimeRemaining(w.restTime)
|
||||
} else if (s.phase === 'REST') {
|
||||
if (s.currentRound >= (workout?.rounds ?? 8)) {
|
||||
store.setPhase('COMPLETE')
|
||||
store.setTimeRemaining(0)
|
||||
store.setRunning(false)
|
||||
} else {
|
||||
intervalRef.current = setTimeout(() => {
|
||||
const s = usePlayerStore.getState()
|
||||
if (!s.isRunning || s.isPaused || s.phase === 'COMPLETE') return
|
||||
|
||||
if (s.timeRemaining <= 1.9) {
|
||||
// Phase transition
|
||||
if (s.phase === 'PREP') {
|
||||
store.setPhase('WORK')
|
||||
store.setTimeRemaining(w.workTime)
|
||||
store.setCurrentRound(s.currentRound + 1)
|
||||
} else if (s.phase === 'WORK') {
|
||||
const caloriesPerRound = workout ? Math.round(workout.calories / workout.rounds) : 5
|
||||
store.addCalories(caloriesPerRound)
|
||||
store.setPhase('REST')
|
||||
store.setTimeRemaining(w.restTime)
|
||||
} else if (s.phase === 'REST') {
|
||||
if (s.currentRound >= (workout?.rounds ?? 8)) {
|
||||
store.setPhase('COMPLETE')
|
||||
store.setTimeRemaining(0)
|
||||
store.setRunning(false)
|
||||
} else {
|
||||
store.setPhase('WORK')
|
||||
store.setTimeRemaining(w.workTime)
|
||||
store.setCurrentRound(s.currentRound + 1)
|
||||
}
|
||||
}
|
||||
// Don't reschedule — phase change triggers effect re-run
|
||||
} else {
|
||||
store.setTimeRemaining(s.timeRemaining - 1)
|
||||
scheduleTick()
|
||||
}
|
||||
} else {
|
||||
store.setTimeRemaining(s.timeRemaining - 1)
|
||||
}
|
||||
}, 1000)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
scheduleTick()
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
clearTimeout(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
"tabs": {
|
||||
"home": "Start",
|
||||
"explore": "Entdecken",
|
||||
"programs": "Programme",
|
||||
"activity": "Aktivität",
|
||||
"progression": "Fortschritt",
|
||||
"profile": "Profil"
|
||||
},
|
||||
|
||||
"home": {
|
||||
"readyToCrush": "Bereit, heute alles zu geben?",
|
||||
"featured": "EMPFOHLEN",
|
||||
"recent": "Zuletzt",
|
||||
"popularThisWeek": "Beliebt diese Woche",
|
||||
@@ -14,11 +17,24 @@
|
||||
"chooseYourPath": "W\u00e4hle deinen Weg",
|
||||
"continueYourJourney": "Setze deine Reise fort",
|
||||
"yourPrograms": "Deine Programme",
|
||||
"programsSubtitle": "W\u00e4hle deinen Fokus",
|
||||
"programsSubtitle": "Entwickelt von Physiotherapeuten",
|
||||
"switchProgram": "Programm wechseln",
|
||||
"statsStreak": "Serie",
|
||||
"statsThisWeek": "Diese Woche",
|
||||
"statsMinutes": "Minuten"
|
||||
"statsMinutes": "Minuten",
|
||||
"upperBody": "Oberkörper",
|
||||
"lowerBody": "Unterkörper",
|
||||
"fullBody": "Ganzkörper",
|
||||
"programsByZone": "Programme nach Zone",
|
||||
"tabataCount": "{{count}} Tabatas",
|
||||
"freeBadge": "KOSTENLOS",
|
||||
"premiumBadge": "PREMIUM",
|
||||
"startProgram": "Starten",
|
||||
"continueProgram": "Fortsetzen",
|
||||
"unlockPremium": "Premium freischalten",
|
||||
"tabataPrograms": "Physio Programme",
|
||||
"tabataProgramsSubtitle": "Rehabilitation und Physiotherapie Programme",
|
||||
"recommendedNext": "Sitzung fortsetzen"
|
||||
},
|
||||
|
||||
"explore": {
|
||||
@@ -372,5 +388,35 @@
|
||||
"tip4": "Kein Urteil - nur ein Ausgangspunkt!",
|
||||
"duration": "Dauer",
|
||||
"exercises": "\u00dcbungen"
|
||||
},
|
||||
|
||||
"kine": {
|
||||
"programs": "Physio-Programme",
|
||||
"recommendedNext": "Nächste empfohlene Einheit",
|
||||
"continueSession": "Einheit fortsetzen",
|
||||
"startSession": "Einheit starten",
|
||||
"unlockPremium": "Mit Premium freischalten",
|
||||
"free": "KOSTENLOS",
|
||||
"premium": "PREMIUM",
|
||||
"weeks": "Wochen",
|
||||
"sessionsPerWeek": "Einheiten/Woche",
|
||||
"sessions": "Einheiten",
|
||||
"deload": "Entlastung",
|
||||
"warmup": "Aufwärmen",
|
||||
"cooldown": "Cool-down",
|
||||
"block": "Block",
|
||||
"rounds": "Runden"
|
||||
},
|
||||
|
||||
"workoutProgram": {
|
||||
"tabata": "Tabata",
|
||||
"exercise1": "Übung 1",
|
||||
"exercise2": "Übung 2",
|
||||
"rounds": "{{count}} Runden",
|
||||
"startProgram": "Programm starten",
|
||||
"tabataLabel": "Tabata {{position}}",
|
||||
"beginner": "Anfänger",
|
||||
"intermediate": "Mittelstufe",
|
||||
"advanced": "Fortgeschritten"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
"tabs": {
|
||||
"home": "Home",
|
||||
"explore": "Explore",
|
||||
"programs": "Programs",
|
||||
"activity": "Activity",
|
||||
"progression": "Progress",
|
||||
"profile": "Profile"
|
||||
},
|
||||
|
||||
"home": {
|
||||
"readyToCrush": "Ready to crush it today?",
|
||||
"featured": "FEATURED",
|
||||
"recent": "Recent",
|
||||
"popularThisWeek": "Popular This Week",
|
||||
@@ -14,11 +17,24 @@
|
||||
"chooseYourPath": "Choose Your Path",
|
||||
"continueYourJourney": "Continue Your Journey",
|
||||
"yourPrograms": "Your Programs",
|
||||
"programsSubtitle": "Choose your focus",
|
||||
"programsSubtitle": "Designed by physiotherapists",
|
||||
"switchProgram": "Switch Program",
|
||||
"statsStreak": "Streak",
|
||||
"statsThisWeek": "This Week",
|
||||
"statsMinutes": "Minutes"
|
||||
"statsMinutes": "Minutes",
|
||||
"upperBody": "Upper Body",
|
||||
"lowerBody": "Lower Body",
|
||||
"fullBody": "Full Body",
|
||||
"programsByZone": "Programs by Body Zone",
|
||||
"tabataCount": "{{count}} tabatas",
|
||||
"freeBadge": "FREE",
|
||||
"premiumBadge": "PREMIUM",
|
||||
"startProgram": "Start",
|
||||
"continueProgram": "Continue",
|
||||
"unlockPremium": "Unlock Premium",
|
||||
"tabataPrograms": "Physio Programs",
|
||||
"tabataProgramsSubtitle": "Rehabilitation and physiotherapy programs",
|
||||
"recommendedNext": "Continue your session"
|
||||
},
|
||||
|
||||
"explore": {
|
||||
@@ -409,5 +425,35 @@
|
||||
"tip4": "No judgment - just a starting point!",
|
||||
"duration": "Duration",
|
||||
"exercises": "Exercises"
|
||||
},
|
||||
|
||||
"kine": {
|
||||
"programs": "Physio Programs",
|
||||
"recommendedNext": "Recommended Next Session",
|
||||
"continueSession": "Continue Session",
|
||||
"startSession": "Start Session",
|
||||
"unlockPremium": "Unlock with Premium",
|
||||
"free": "FREE",
|
||||
"premium": "PREMIUM",
|
||||
"weeks": "weeks",
|
||||
"sessionsPerWeek": "sessions/week",
|
||||
"sessions": "sessions",
|
||||
"deload": "Deload",
|
||||
"warmup": "Warmup",
|
||||
"cooldown": "Cooldown",
|
||||
"block": "Block",
|
||||
"rounds": "rounds"
|
||||
},
|
||||
|
||||
"workoutProgram": {
|
||||
"tabata": "Tabata",
|
||||
"exercise1": "Exercise 1",
|
||||
"exercise2": "Exercise 2",
|
||||
"rounds": "{{count}} rounds",
|
||||
"startProgram": "Start Program",
|
||||
"tabataLabel": "Tabata {{position}}",
|
||||
"beginner": "Beginner",
|
||||
"intermediate": "Intermediate",
|
||||
"advanced": "Advanced"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
"tabs": {
|
||||
"home": "Inicio",
|
||||
"explore": "Explorar",
|
||||
"programs": "Programas",
|
||||
"activity": "Actividad",
|
||||
"progression": "Progresión",
|
||||
"profile": "Perfil"
|
||||
},
|
||||
|
||||
"home": {
|
||||
"readyToCrush": "¿Listo para arrasar hoy?",
|
||||
"featured": "DESTACADO",
|
||||
"recent": "Recientes",
|
||||
"popularThisWeek": "Popular esta semana",
|
||||
@@ -14,11 +17,24 @@
|
||||
"chooseYourPath": "Elige tu camino",
|
||||
"continueYourJourney": "Contin\u00faa tu viaje",
|
||||
"yourPrograms": "Tus programas",
|
||||
"programsSubtitle": "Elige tu enfoque",
|
||||
"programsSubtitle": "Diseñados por fisioterapeutas",
|
||||
"switchProgram": "Cambiar programa",
|
||||
"statsStreak": "Racha",
|
||||
"statsThisWeek": "Esta semana",
|
||||
"statsMinutes": "Minutos"
|
||||
"statsMinutes": "Minutos",
|
||||
"upperBody": "Parte superior",
|
||||
"lowerBody": "Parte inferior",
|
||||
"fullBody": "Cuerpo completo",
|
||||
"programsByZone": "Programas por zona",
|
||||
"tabataCount": "{{count}} tabatas",
|
||||
"freeBadge": "GRATIS",
|
||||
"premiumBadge": "PREMIUM",
|
||||
"startProgram": "Empezar",
|
||||
"continueProgram": "Continuar",
|
||||
"unlockPremium": "Desbloquear Premium",
|
||||
"tabataPrograms": "Programas de fisio",
|
||||
"tabataProgramsSubtitle": "Programas de rehabilitación y fisioterapia",
|
||||
"recommendedNext": "Continuar tu sesión"
|
||||
},
|
||||
|
||||
"explore": {
|
||||
@@ -372,5 +388,35 @@
|
||||
"tip4": "Sin juicios - ¡solo un punto de partida!",
|
||||
"duration": "Duración",
|
||||
"exercises": "Ejercicios"
|
||||
},
|
||||
|
||||
"kine": {
|
||||
"programs": "Programas de Fisio",
|
||||
"recommendedNext": "Próxima sesión recomendada",
|
||||
"continueSession": "Continuar sesión",
|
||||
"startSession": "Comenzar sesión",
|
||||
"unlockPremium": "Desbloquear con Premium",
|
||||
"free": "GRATIS",
|
||||
"premium": "PREMIUM",
|
||||
"weeks": "semanas",
|
||||
"sessionsPerWeek": "sesiones/sem",
|
||||
"sessions": "sesiones",
|
||||
"deload": "Descarga",
|
||||
"warmup": "Calentamiento",
|
||||
"cooldown": "Enfriamiento",
|
||||
"block": "Bloque",
|
||||
"rounds": "rondas"
|
||||
},
|
||||
|
||||
"workoutProgram": {
|
||||
"tabata": "Tabata",
|
||||
"exercise1": "Ejercicio 1",
|
||||
"exercise2": "Ejercicio 2",
|
||||
"rounds": "{{count}} rondas",
|
||||
"startProgram": "Iniciar programa",
|
||||
"tabataLabel": "Tabata {{position}}",
|
||||
"beginner": "Principiante",
|
||||
"intermediate": "Intermedio",
|
||||
"advanced": "Avanzado"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
"tabs": {
|
||||
"home": "Accueil",
|
||||
"explore": "Explorer",
|
||||
"programs": "Programmes",
|
||||
"activity": "Activité",
|
||||
"progression": "Progression",
|
||||
"profile": "Profil"
|
||||
},
|
||||
|
||||
"home": {
|
||||
"readyToCrush": "Prêt à tout casser aujourd'hui ?",
|
||||
"featured": "\u00c0 LA UNE",
|
||||
"recent": "R\u00e9cents",
|
||||
"popularThisWeek": "Populaires cette semaine",
|
||||
@@ -14,11 +17,24 @@
|
||||
"chooseYourPath": "Choisissez votre parcours",
|
||||
"continueYourJourney": "Continuez votre parcours",
|
||||
"yourPrograms": "Vos programmes",
|
||||
"programsSubtitle": "Choisissez votre focus",
|
||||
"programsSubtitle": "Conçus par des kinésithérapeutes",
|
||||
"switchProgram": "Changer de programme",
|
||||
"statsStreak": "S\u00e9rie",
|
||||
"statsThisWeek": "Cette semaine",
|
||||
"statsMinutes": "Minutes"
|
||||
"statsMinutes": "Minutes",
|
||||
"upperBody": "Haut du corps",
|
||||
"lowerBody": "Bas du corps",
|
||||
"fullBody": "Corps entier",
|
||||
"programsByZone": "Programmes par zone",
|
||||
"tabataCount": "{{count}} tabatas",
|
||||
"freeBadge": "GRATUIT",
|
||||
"premiumBadge": "PREMIUM",
|
||||
"startProgram": "Démarrer",
|
||||
"continueProgram": "Continuer",
|
||||
"unlockPremium": "Débloquer Premium",
|
||||
"tabataPrograms": "Programmes Tabata",
|
||||
"tabataProgramsSubtitle": "Programmes de rééducation et physiothérapie",
|
||||
"recommendedNext": "Continuer votre séance"
|
||||
},
|
||||
|
||||
"explore": {
|
||||
@@ -409,5 +425,35 @@
|
||||
"tip4": "Sans jugement - juste un point de départ !",
|
||||
"duration": "Durée",
|
||||
"exercises": "Exercices"
|
||||
},
|
||||
|
||||
"kine": {
|
||||
"programs": "Programmes Tabata",
|
||||
"recommendedNext": "Prochaine séance recommandée",
|
||||
"continueSession": "Continuer la séance",
|
||||
"startSession": "Commencer la séance",
|
||||
"unlockPremium": "Débloquer avec Premium",
|
||||
"free": "GRATUIT",
|
||||
"premium": "PREMIUM",
|
||||
"weeks": "semaines",
|
||||
"sessionsPerWeek": "séances/sem",
|
||||
"sessions": "séances",
|
||||
"deload": "Décharge",
|
||||
"warmup": "Échauffement",
|
||||
"cooldown": "Retour au calme",
|
||||
"block": "Bloc",
|
||||
"rounds": "rounds"
|
||||
},
|
||||
|
||||
"workoutProgram": {
|
||||
"tabata": "Tabata",
|
||||
"exercise1": "Exercice 1",
|
||||
"exercise2": "Exercice 2",
|
||||
"rounds": "{{count}} rounds",
|
||||
"startProgram": "Démarrer le programme",
|
||||
"tabataLabel": "Tabata {{position}}",
|
||||
"beginner": "Débutant",
|
||||
"intermediate": "Intermédiaire",
|
||||
"advanced": "Avancé"
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,9 @@
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Feb 28, 2026
|
||||
### Apr 17, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5591 | 7:56 PM | ✅ | PostHog analytics enabled in development mode | ~249 |
|
||||
| #5590 | 7:51 PM | 🔴 | Fixed screenshotMode configuration typo | ~177 |
|
||||
| #5581 | 7:48 PM | 🟣 | PostHog session replay and advanced analytics enabled | ~370 |
|
||||
| #5580 | " | 🔵 | PostHog configuration shows session replay disabled | ~229 |
|
||||
| #5577 | 7:45 PM | 🟣 | PostHog API key configured in analytics service | ~255 |
|
||||
| #5576 | 7:44 PM | 🔵 | PostHog service uses placeholder API key | ~298 |
|
||||
| #5574 | " | 🔵 | Analytics service file located | ~193 |
|
||||
| #5572 | 7:43 PM | 🔵 | PostHog integration points identified | ~228 |
|
||||
| #6384 | 10:35 AM | 🔄 | Enhanced workout program access control with explicit boolean checks | ~352 |
|
||||
</claude-mem-context>
|
||||
@@ -1,11 +1,87 @@
|
||||
/**
|
||||
* TabataFit Access Control Service
|
||||
* Manages free tier vs premium workout access
|
||||
* Tabata Access Control Service
|
||||
* Manages free tier vs premium access
|
||||
*
|
||||
* Freemium model: 3 free workouts, rest behind paywall
|
||||
* Workout Programs: Free status from database is_free flag
|
||||
* Tabata: Free = entire Débutant program, Premium = everything else
|
||||
* Legacy: 3 free workouts kept for backward compatibility
|
||||
*/
|
||||
|
||||
/** Workout IDs available without a subscription */
|
||||
import type { TabataProgramId } from '../types/program'
|
||||
import type { WorkoutProgram } from '../types/workoutProgram'
|
||||
|
||||
// ─── Tabata Program Access ───────────────────────────────────────
|
||||
|
||||
/** Program available without a subscription */
|
||||
export const FREE_PROGRAM_ID: TabataProgramId = 'debutant'
|
||||
|
||||
/** Session ID prefix → program mapping */
|
||||
const SESSION_PREFIX_MAP: Record<string, TabataProgramId> = {
|
||||
'deb-': 'debutant',
|
||||
'int-': 'intermediaire',
|
||||
'avc-': 'avance',
|
||||
'bur-': 'bureau',
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a program is part of the free tier
|
||||
*/
|
||||
export function isFreeProgram(programId: TabataProgramId): boolean {
|
||||
return programId === FREE_PROGRAM_ID
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can access a tabata program
|
||||
*/
|
||||
export function canAccessProgram(programId: TabataProgramId, isPremium: boolean): boolean {
|
||||
if (isPremium) return true
|
||||
return isFreeProgram(programId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can access a specific tabata session
|
||||
* Extracts program from session ID prefix (e.g., 'deb-w1-s1' → 'debutant')
|
||||
*/
|
||||
export function canAccessSession(sessionId: string, isPremium: boolean): boolean {
|
||||
if (isPremium) return true
|
||||
for (const [prefix, programId] of Object.entries(SESSION_PREFIX_MAP)) {
|
||||
if (sessionId.startsWith(prefix)) {
|
||||
return isFreeProgram(programId)
|
||||
}
|
||||
}
|
||||
// Unknown prefix — deny by default
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the program ID from a session ID
|
||||
*/
|
||||
export function getSessionProgramId(sessionId: string): TabataProgramId | undefined {
|
||||
for (const [prefix, programId] of Object.entries(SESSION_PREFIX_MAP)) {
|
||||
if (sessionId.startsWith(prefix)) return programId
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ─── Workout Program Access ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if user can access a workout program
|
||||
* Free status is determined by the is_free flag in the database.
|
||||
* Falls back to level-based rules if isFree is undefined.
|
||||
*/
|
||||
export function canAccessWorkoutProgram(
|
||||
program: WorkoutProgram,
|
||||
isPremium: boolean,
|
||||
): boolean {
|
||||
if (isPremium) return true
|
||||
// Handle boolean, string ("true"), and truthy values from Supabase/cache
|
||||
return program.isFree === true
|
||||
}
|
||||
|
||||
// ─── Legacy Workout Access (kept for backward compatibility) ──
|
||||
|
||||
/** Workout IDs available without a subscription (legacy) */
|
||||
export const FREE_WORKOUT_IDS: readonly string[] = [
|
||||
'1', // Full Body Ignite — Beginner, 4 min (full-body)
|
||||
'11', // Core Crusher — Intermediate, 4 min (core)
|
||||
@@ -16,17 +92,20 @@ export const FREE_WORKOUT_IDS: readonly string[] = [
|
||||
export const FREE_WORKOUT_COUNT = FREE_WORKOUT_IDS.length
|
||||
|
||||
/**
|
||||
* Check if a specific workout is part of the free tier
|
||||
* Check if a specific workout is part of the free tier (legacy)
|
||||
*/
|
||||
export function isFreeWorkout(workoutId: string): boolean {
|
||||
return FREE_WORKOUT_IDS.includes(workoutId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can access a workout
|
||||
* Premium users can access everything; free users only get FREE_WORKOUT_IDS
|
||||
* Check if user can access a workout (legacy + tabata)
|
||||
* Premium users can access everything; free users get free tier only
|
||||
*/
|
||||
export function canAccessWorkout(workoutId: string, isPremium: boolean): boolean {
|
||||
if (isPremium) return true
|
||||
// Check tabata session access
|
||||
if (canAccessSession(workoutId, false)) return true
|
||||
// Check legacy workout access
|
||||
return isFreeWorkout(workoutId)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* - Session replay enabled for onboarding funnel analysis
|
||||
*/
|
||||
|
||||
import { logger } from '../utils/logger'
|
||||
import PostHog, { PostHogOptions } from 'posthog-react-native'
|
||||
|
||||
type EventProperties = Record<string, string | number | boolean | string[]>
|
||||
@@ -26,14 +27,14 @@ let posthogClient: PostHog | null = null
|
||||
*/
|
||||
export async function initializeAnalytics(): Promise<PostHog | null> {
|
||||
if (posthogClient) {
|
||||
console.log('[Analytics] Already initialized')
|
||||
logger.log('[Analytics] Already initialized')
|
||||
return posthogClient
|
||||
}
|
||||
|
||||
// Skip initialization if no real API key is configured
|
||||
if (POSTHOG_API_KEY.startsWith('__')) {
|
||||
if (__DEV__) {
|
||||
console.log('[Analytics] No API key configured — events will be logged to console only')
|
||||
logger.log('[Analytics] No API key configured — events will be logged to console only')
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -51,10 +52,10 @@ export async function initializeAnalytics(): Promise<PostHog | null> {
|
||||
|
||||
posthogClient = new PostHog(POSTHOG_API_KEY, config)
|
||||
|
||||
console.log('[Analytics] PostHog initialized with session replay')
|
||||
logger.log('[Analytics] PostHog initialized with session replay')
|
||||
return posthogClient
|
||||
} catch (error) {
|
||||
console.error('[Analytics] Failed to initialize PostHog:', error)
|
||||
logger.error('[Analytics] Failed to initialize PostHog:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -72,7 +73,7 @@ export function getPostHogClient(): PostHog | null {
|
||||
*/
|
||||
export function track(event: string, properties?: EventProperties): void {
|
||||
if (__DEV__) {
|
||||
console.log(`[Analytics] ${event}`, properties ?? '')
|
||||
logger.log(`[Analytics] ${event}`, properties ?? '')
|
||||
}
|
||||
posthogClient?.capture(event, properties)
|
||||
}
|
||||
@@ -93,7 +94,7 @@ export function identifyUser(
|
||||
traits?: EventProperties,
|
||||
): void {
|
||||
if (__DEV__) {
|
||||
console.log('[Analytics] identify', userId, traits ?? '')
|
||||
logger.log('[Analytics] identify', userId, traits ?? '')
|
||||
}
|
||||
posthogClient?.identify(userId, traits)
|
||||
}
|
||||
@@ -103,7 +104,7 @@ export function identifyUser(
|
||||
*/
|
||||
export function setUserProperties(properties: EventProperties): void {
|
||||
if (__DEV__) {
|
||||
console.log('[Analytics] set user properties', properties)
|
||||
logger.log('[Analytics] set user properties', properties)
|
||||
}
|
||||
posthogClient?.setPersonProperties(properties)
|
||||
}
|
||||
@@ -114,7 +115,7 @@ export function setUserProperties(properties: EventProperties): void {
|
||||
*/
|
||||
export function startSessionRecording(): void {
|
||||
if (__DEV__) {
|
||||
console.log('[Analytics] start session recording')
|
||||
logger.log('[Analytics] start session recording')
|
||||
}
|
||||
posthogClient?.startSessionRecording()
|
||||
}
|
||||
@@ -124,7 +125,7 @@ export function startSessionRecording(): void {
|
||||
*/
|
||||
export function stopSessionRecording(): void {
|
||||
if (__DEV__) {
|
||||
console.log('[Analytics] stop session recording')
|
||||
logger.log('[Analytics] stop session recording')
|
||||
}
|
||||
posthogClient?.stopSessionRecording()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { supabase, isSupabaseConfigured } from '../supabase'
|
||||
import { logger } from '../utils/logger'
|
||||
import type { MusicVibe } from '../types'
|
||||
|
||||
export interface MusicTrack {
|
||||
@@ -42,6 +43,20 @@ const MOCK_TRACKS: Record<MusicVibe, MusicTrack[]> = {
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps download_items genres to workout MusicVibe values.
|
||||
* Multiple genres map to the same vibe so we get more tracks per vibe.
|
||||
*/
|
||||
const VIBE_TO_GENRES: Record<MusicVibe, string[]> = {
|
||||
electronic: ['edm', 'house', 'drum-and-bass', 'dubstep'],
|
||||
'hip-hop': ['hip-hop', 'r-and-b'],
|
||||
pop: ['pop', 'latin'],
|
||||
rock: ['rock', 'metal', 'country'],
|
||||
chill: ['ambient'],
|
||||
}
|
||||
|
||||
const SUPABASE_URL = process.env.EXPO_PUBLIC_SUPABASE_URL || 'http://localhost:54321'
|
||||
|
||||
class MusicService {
|
||||
private cache: Map<MusicVibe, MusicTrack[]> = new Map()
|
||||
|
||||
@@ -51,64 +66,72 @@ class MusicService {
|
||||
}
|
||||
|
||||
if (!isSupabaseConfigured()) {
|
||||
console.log(`[Music] Using mock tracks for vibe: ${vibe}`)
|
||||
logger.log(`[Music] Using mock tracks for vibe: ${vibe}`)
|
||||
return MOCK_TRACKS[vibe] || MOCK_TRACKS.electronic
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: files, error } = await supabase
|
||||
.storage
|
||||
.from('music')
|
||||
.list(vibe)
|
||||
const genres = VIBE_TO_GENRES[vibe] || []
|
||||
|
||||
type DownloadItemRow = {
|
||||
id: string | null
|
||||
video_id: string | null
|
||||
title: string | null
|
||||
duration_seconds: number | null
|
||||
public_url: string | null
|
||||
storage_path: string | null
|
||||
genre: string | null
|
||||
}
|
||||
|
||||
const { data: items, error } = await supabase
|
||||
.from('download_items')
|
||||
.select('id, video_id, title, duration_seconds, public_url, storage_path, genre')
|
||||
.eq('status', 'completed')
|
||||
.in('genre', genres)
|
||||
.limit(50) as { data: DownloadItemRow[] | null; error: Error | null }
|
||||
|
||||
if (error) {
|
||||
console.error('[Music] Error loading tracks:', error)
|
||||
logger.error('[Music] Error loading tracks:', error)
|
||||
return MOCK_TRACKS[vibe] || MOCK_TRACKS.electronic
|
||||
}
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
console.log(`[Music] No tracks found for vibe: ${vibe}, using mock data`)
|
||||
if (!items || items.length === 0) {
|
||||
logger.log(`[Music] No tracks found for vibe: ${vibe}, using mock data`)
|
||||
return MOCK_TRACKS[vibe] || MOCK_TRACKS.electronic
|
||||
}
|
||||
|
||||
const tracks: MusicTrack[] = await Promise.all(
|
||||
files
|
||||
.filter(file => file.name.endsWith('.mp3') || file.name.endsWith('.m4a'))
|
||||
.map(async (file, index) => {
|
||||
const { data: urlData } = await supabase
|
||||
.storage
|
||||
.from('music')
|
||||
.createSignedUrl(`${vibe}/${file.name}`, 3600)
|
||||
const tracks: MusicTrack[] = items
|
||||
.filter(item => item.public_url || item.storage_path)
|
||||
.map((item, index) => {
|
||||
const url = item.public_url
|
||||
|| `${SUPABASE_URL}/storage/v1/object/public/workout-audio/${item.storage_path}`
|
||||
|
||||
const fileName = file.name.replace(/\.[^/.]+$/, '')
|
||||
const [artist, title] = fileName.includes(' - ')
|
||||
? fileName.split(' - ')
|
||||
: ['Unknown Artist', fileName]
|
||||
const titleStr = item.title || 'Unknown Track'
|
||||
const [artist, trackTitle] = titleStr.includes(' - ')
|
||||
? titleStr.split(' - ').map((s: string) => s.trim())
|
||||
: ['YouTube Music', titleStr]
|
||||
|
||||
return {
|
||||
id: `${vibe}-${index}`,
|
||||
title: title || fileName,
|
||||
artist: artist || 'Unknown Artist',
|
||||
duration: 180,
|
||||
url: urlData?.signedUrl || '',
|
||||
vibe,
|
||||
}
|
||||
})
|
||||
)
|
||||
return {
|
||||
id: item.id || `${vibe}-${index}`,
|
||||
title: trackTitle || titleStr,
|
||||
artist: artist || 'Unknown Artist',
|
||||
duration: item.duration_seconds ?? 180,
|
||||
url,
|
||||
vibe,
|
||||
}
|
||||
})
|
||||
|
||||
const validTracks = tracks.filter(track => track.url)
|
||||
|
||||
if (validTracks.length === 0) {
|
||||
console.log(`[Music] No valid tracks for vibe: ${vibe}, using mock data`)
|
||||
if (tracks.length === 0) {
|
||||
logger.log(`[Music] No valid tracks for vibe: ${vibe}, using mock data`)
|
||||
return MOCK_TRACKS[vibe] || MOCK_TRACKS.electronic
|
||||
}
|
||||
|
||||
this.cache.set(vibe, validTracks)
|
||||
console.log(`[Music] Loaded ${validTracks.length} tracks for vibe: ${vibe}`)
|
||||
this.cache.set(vibe, tracks)
|
||||
logger.log(`[Music] Loaded ${tracks.length} tracks for vibe: ${vibe}`)
|
||||
|
||||
return validTracks
|
||||
return tracks
|
||||
} catch (error) {
|
||||
console.error('[Music] Error loading tracks:', error)
|
||||
logger.error('[Music] Error loading tracks:', error)
|
||||
return MOCK_TRACKS[vibe] || MOCK_TRACKS.electronic
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* - Transactions are free sandbox completions — no real charges
|
||||
*/
|
||||
|
||||
import { logger } from '../utils/logger'
|
||||
import Purchases, { LOG_LEVEL } from 'react-native-purchases'
|
||||
|
||||
// RevenueCat configuration
|
||||
@@ -28,7 +29,7 @@ let isInitialized = false
|
||||
*/
|
||||
export async function initializePurchases(): Promise<void> {
|
||||
if (isInitialized) {
|
||||
console.log('[Purchases] Already initialized')
|
||||
logger.log('[Purchases] Already initialized')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -42,10 +43,10 @@ export async function initializePurchases(): Promise<void> {
|
||||
await Purchases.configure({ apiKey: REVENUECAT_API_KEY })
|
||||
|
||||
isInitialized = true
|
||||
console.log('[Purchases] RevenueCat initialized successfully')
|
||||
console.log('[Purchases] Sandbox mode:', __DEV__ ? 'enabled' : 'disabled')
|
||||
logger.log('[Purchases] RevenueCat initialized successfully')
|
||||
logger.log('[Purchases] Sandbox mode:', __DEV__ ? 'enabled' : 'disabled')
|
||||
} catch (error) {
|
||||
console.error('[Purchases] Failed to initialize RevenueCat:', error)
|
||||
logger.error('[Purchases] Failed to initialize RevenueCat:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Handles opt-in personalization for premium users
|
||||
*/
|
||||
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
import { supabase } from '@/src/shared/supabase'
|
||||
import type {
|
||||
UserProfileData,
|
||||
@@ -78,7 +80,7 @@ export async function enableSync(
|
||||
|
||||
return { success: true, userId }
|
||||
} catch (error) {
|
||||
console.error('Failed to enable sync:', error)
|
||||
logger.error('Failed to enable sync:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
@@ -110,7 +112,7 @@ export async function syncWorkoutSession(
|
||||
if (error) throw error
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Failed to sync workout:', error)
|
||||
logger.error('Failed to sync workout:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
@@ -160,7 +162,7 @@ export async function deleteSyncedData(): Promise<DeleteDataResult> {
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Failed to delete synced data:', error)
|
||||
logger.error('Failed to delete synced data:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
||||
@@ -3,5 +3,25 @@
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
*No recent activity*
|
||||
### Apr 10, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6034 | 10:10 AM | 🔵 | Kine Program Store Re-Read for Progress Tracking Reference | ~376 |
|
||||
| #6026 | 10:08 AM | 🔵 | Kine Program Store with Progress Tracking and Sequential Unlocking | ~566 |
|
||||
|
||||
### Apr 13, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6175 | 10:04 PM | 🟣 | Completed Explore Tab Removal | ~196 |
|
||||
| #6172 | 10:03 PM | ✅ | Removed Explore Filter Store Export | ~123 |
|
||||
| #6171 | " | 🔵 | Read Stores Barrel Export File | ~127 |
|
||||
| #6161 | 10:02 PM | 🔵 | Discovered Explore Filter Store | ~135 |
|
||||
|
||||
### Apr 17, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6383 | 10:35 AM | 🔵 | User Store Subscription State Management | ~172 |
|
||||
</claude-mem-context>
|
||||
@@ -6,4 +6,5 @@ export { useUserStore } from './userStore'
|
||||
export { useActivityStore, getWeeklyActivity } from './activityStore'
|
||||
export { usePlayerStore } from './playerStore'
|
||||
export { useProgramStore } from './programStore'
|
||||
export { useKineProgramStore } from './kineProgramStore'
|
||||
export { useTabataProgramStore } from './tabataProgramStore'
|
||||
export { useWorkoutProgramStore } from './workoutProgramStore'
|
||||
|
||||
335
src/shared/stores/tabataProgramStore.ts
Normal file
335
src/shared/stores/tabataProgramStore.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Tabata Program Store
|
||||
* Handles program selection, progression tracking, and sequential unlocking
|
||||
* for the 4 tabata programs (debutant, intermediaire, avance, bureau)
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import type {
|
||||
TabataProgramId,
|
||||
TabataProgramProgress,
|
||||
} from '../types/program'
|
||||
import type { TabataProgram, TabataSession } from '../types/program'
|
||||
import { getTabataProgramById, getAllTabataPrograms, getTabataSessionById } from '../data/tabata'
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────
|
||||
|
||||
interface TabataProgramState {
|
||||
// Program selection
|
||||
selectedProgramId: TabataProgramId | null
|
||||
|
||||
// Progress tracking for each program
|
||||
programsProgress: Record<TabataProgramId, TabataProgramProgress>
|
||||
|
||||
// Actions
|
||||
selectProgram: (programId: TabataProgramId) => void
|
||||
completeSession: (programId: TabataProgramId, sessionId: string) => void
|
||||
resetProgram: (programId: TabataProgramId) => void
|
||||
changeProgram: (programId: TabataProgramId) => void
|
||||
|
||||
// Getters
|
||||
getCurrentSession: (programId: TabataProgramId) => TabataSession | null
|
||||
getNextSession: (programId: TabataProgramId) => TabataSession | null
|
||||
isWeekUnlocked: (programId: TabataProgramId, weekNumber: number) => boolean
|
||||
isSessionUnlocked: (programId: TabataProgramId, sessionId: string) => boolean
|
||||
getProgramCompletion: (programId: TabataProgramId) => number
|
||||
getTotalSessionsCompleted: () => number
|
||||
getProgramStatus: (programId: TabataProgramId) => 'not-started' | 'in-progress' | 'completed'
|
||||
getRecommendedNext: () => { programId: TabataProgramId; session: TabataSession } | null
|
||||
getProgram: (programId: TabataProgramId) => TabataProgram | undefined
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
const PROGRAM_IDS: TabataProgramId[] = ['debutant', 'intermediaire', 'avance', 'bureau']
|
||||
|
||||
const createInitialProgress = (programId: TabataProgramId): TabataProgramProgress => ({
|
||||
programId,
|
||||
currentWeek: 1,
|
||||
currentSessionIndex: 0,
|
||||
completedSessionIds: [],
|
||||
isProgramCompleted: false,
|
||||
startDate: undefined,
|
||||
lastSessionDate: undefined,
|
||||
})
|
||||
|
||||
const createAllProgress = (): Record<TabataProgramId, TabataProgramProgress> => {
|
||||
const result = {} as Record<TabataProgramId, TabataProgramProgress>
|
||||
for (const id of PROGRAM_IDS) {
|
||||
result[id] = createInitialProgress(id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Get sessions for a specific week of a program */
|
||||
const getWeekSessions = (program: TabataProgram, weekNumber: number): TabataSession[] => {
|
||||
const week = program.weeks.find(w => w.weekNumber === weekNumber)
|
||||
return week?.sessions ?? []
|
||||
}
|
||||
|
||||
// ─── Store ─────────────────────────────────────────────────────
|
||||
|
||||
export const useTabataProgramStore = create<TabataProgramState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// Initial state
|
||||
selectedProgramId: null,
|
||||
programsProgress: createAllProgress(),
|
||||
|
||||
// Select a program to start
|
||||
selectProgram: (programId) => {
|
||||
const state = get()
|
||||
const progress = state.programsProgress[programId]
|
||||
|
||||
if (progress.completedSessionIds.length === 0 && !progress.startDate) {
|
||||
set((state) => ({
|
||||
selectedProgramId: programId,
|
||||
programsProgress: {
|
||||
...state.programsProgress,
|
||||
[programId]: {
|
||||
...progress,
|
||||
startDate: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
set({ selectedProgramId: programId })
|
||||
}
|
||||
},
|
||||
|
||||
// Complete a session and advance progress
|
||||
completeSession: (programId, sessionId) => {
|
||||
const state = get()
|
||||
const progress = state.programsProgress[programId]
|
||||
const program = getTabataProgramById(programId)
|
||||
|
||||
if (!program) return
|
||||
|
||||
// Avoid duplicate completions
|
||||
if (progress.completedSessionIds.includes(sessionId)) return
|
||||
|
||||
const newCompletedIds = [...progress.completedSessionIds, sessionId]
|
||||
|
||||
// Find current session position
|
||||
let newWeek = progress.currentWeek
|
||||
let newSessionIndex = progress.currentSessionIndex
|
||||
|
||||
const currentWeekSessions = getWeekSessions(program, progress.currentWeek)
|
||||
const isLastSessionOfWeek = progress.currentSessionIndex >= currentWeekSessions.length - 1
|
||||
|
||||
if (isLastSessionOfWeek && progress.currentWeek < program.durationWeeks) {
|
||||
// Move to next week
|
||||
newWeek = progress.currentWeek + 1
|
||||
newSessionIndex = 0
|
||||
} else if (!isLastSessionOfWeek) {
|
||||
// Move to next session in same week
|
||||
newSessionIndex = progress.currentSessionIndex + 1
|
||||
}
|
||||
|
||||
// Check if program is completed
|
||||
const isProgramCompleted = newCompletedIds.length >= program.totalSessions
|
||||
|
||||
set((state) => ({
|
||||
programsProgress: {
|
||||
...state.programsProgress,
|
||||
[programId]: {
|
||||
...progress,
|
||||
completedSessionIds: newCompletedIds,
|
||||
currentWeek: newWeek,
|
||||
currentSessionIndex: newSessionIndex,
|
||||
isProgramCompleted,
|
||||
lastSessionDate: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
},
|
||||
|
||||
// Reset a program to start over
|
||||
resetProgram: (programId) => {
|
||||
set((state) => ({
|
||||
programsProgress: {
|
||||
...state.programsProgress,
|
||||
[programId]: createInitialProgress(programId),
|
||||
},
|
||||
selectedProgramId: state.selectedProgramId === programId ? null : state.selectedProgramId,
|
||||
}))
|
||||
},
|
||||
|
||||
// Change to a different program
|
||||
changeProgram: (programId) => {
|
||||
set({ selectedProgramId: programId })
|
||||
},
|
||||
|
||||
// Get the current session for a program
|
||||
getCurrentSession: (programId) => {
|
||||
const state = get()
|
||||
const progress = state.programsProgress[programId]
|
||||
const program = getTabataProgramById(programId)
|
||||
|
||||
if (!program || !progress) return null
|
||||
|
||||
const week = program.weeks.find(w => w.weekNumber === progress.currentWeek)
|
||||
if (!week) return null
|
||||
|
||||
return week.sessions[progress.currentSessionIndex] ?? null
|
||||
},
|
||||
|
||||
// Get the next session
|
||||
getNextSession: (programId) => {
|
||||
const state = get()
|
||||
const progress = state.programsProgress[programId]
|
||||
const program = getTabataProgramById(programId)
|
||||
|
||||
if (!program || !progress) return null
|
||||
|
||||
const currentWeek = program.weeks.find(w => w.weekNumber === progress.currentWeek)
|
||||
if (!currentWeek) return null
|
||||
|
||||
if (progress.currentSessionIndex < currentWeek.sessions.length - 1) {
|
||||
return currentWeek.sessions[progress.currentSessionIndex + 1]
|
||||
}
|
||||
|
||||
if (progress.currentWeek < program.durationWeeks) {
|
||||
const nextWeek = program.weeks.find(w => w.weekNumber === progress.currentWeek + 1)
|
||||
return nextWeek?.sessions[0] ?? null
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
// Check if a week is unlocked
|
||||
isWeekUnlocked: (programId, weekNumber) => {
|
||||
if (weekNumber === 1) return true
|
||||
|
||||
const state = get()
|
||||
const progress = state.programsProgress[programId]
|
||||
const program = getTabataProgramById(programId)
|
||||
|
||||
if (!program) return false
|
||||
|
||||
const previousWeek = program.weeks.find(w => w.weekNumber === weekNumber - 1)
|
||||
if (!previousWeek) return false
|
||||
|
||||
const previousWeekSessionIds = previousWeek.sessions.map(s => s.id)
|
||||
return previousWeekSessionIds.every(id => progress.completedSessionIds.includes(id))
|
||||
},
|
||||
|
||||
// Check if a specific session is unlocked
|
||||
isSessionUnlocked: (programId, sessionId) => {
|
||||
const state = get()
|
||||
const progress = state.programsProgress[programId]
|
||||
const program = getTabataProgramById(programId)
|
||||
|
||||
if (!program || !progress) return false
|
||||
|
||||
for (const week of program.weeks) {
|
||||
const sessionIndex = week.sessions.findIndex(s => s.id === sessionId)
|
||||
if (sessionIndex === -1) continue
|
||||
|
||||
// Week must be unlocked
|
||||
if (!get().isWeekUnlocked(programId, week.weekNumber)) return false
|
||||
|
||||
// If completed, always accessible
|
||||
if (progress.completedSessionIds.includes(sessionId)) return true
|
||||
|
||||
// Must be at or before current position
|
||||
if (week.weekNumber === progress.currentWeek) {
|
||||
return sessionIndex <= progress.currentSessionIndex
|
||||
}
|
||||
|
||||
if (week.weekNumber < progress.currentWeek) return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
// Get completion percentage
|
||||
getProgramCompletion: (programId) => {
|
||||
const state = get()
|
||||
const progress = state.programsProgress[programId]
|
||||
const program = getTabataProgramById(programId)
|
||||
|
||||
if (!progress || !program) return 0
|
||||
|
||||
return Math.round((progress.completedSessionIds.length / program.totalSessions) * 100)
|
||||
},
|
||||
|
||||
// Get total sessions completed across all programs
|
||||
getTotalSessionsCompleted: () => {
|
||||
const state = get()
|
||||
return Object.values(state.programsProgress).reduce(
|
||||
(total, progress) => total + progress.completedSessionIds.length,
|
||||
0,
|
||||
)
|
||||
},
|
||||
|
||||
// Get program status
|
||||
getProgramStatus: (programId) => {
|
||||
const state = get()
|
||||
const progress = state.programsProgress[programId]
|
||||
|
||||
if (!progress || progress.completedSessionIds.length === 0) return 'not-started'
|
||||
if (progress.isProgramCompleted) return 'completed'
|
||||
return 'in-progress'
|
||||
},
|
||||
|
||||
// Get recommended next session
|
||||
getRecommendedNext: () => {
|
||||
const state = get()
|
||||
|
||||
// If a program is selected, recommend current session
|
||||
if (state.selectedProgramId) {
|
||||
const session = get().getCurrentSession(state.selectedProgramId)
|
||||
if (session) {
|
||||
return { programId: state.selectedProgramId, session }
|
||||
}
|
||||
}
|
||||
|
||||
// Find any in-progress program
|
||||
for (const programId of PROGRAM_IDS) {
|
||||
const status = get().getProgramStatus(programId)
|
||||
if (status === 'in-progress') {
|
||||
const session = get().getCurrentSession(programId)
|
||||
if (session) return { programId, session }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
// Get program data
|
||||
getProgram: (programId) => {
|
||||
return getTabataProgramById(programId)
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'tabatafit-kine-program-storage',
|
||||
version: 2,
|
||||
storage: {
|
||||
getItem: async (name) => {
|
||||
const value = await AsyncStorage.getItem(name)
|
||||
return value ? JSON.parse(value) : null
|
||||
},
|
||||
setItem: async (name, value) => {
|
||||
await AsyncStorage.setItem(name, JSON.stringify(value))
|
||||
},
|
||||
removeItem: async (name) => {
|
||||
await AsyncStorage.removeItem(name)
|
||||
},
|
||||
},
|
||||
migrate: (persisted, version): TabataProgramState => {
|
||||
// Migrate from v1 (old 3-program model) to v2 (tabata 4-program model)
|
||||
if (version === undefined || version < 2) {
|
||||
// Old format had upper-body/lower-body/full-body keys — reset entirely
|
||||
return {
|
||||
selectedProgramId: null,
|
||||
programsProgress: createAllProgress(),
|
||||
} as TabataProgramState
|
||||
}
|
||||
return persisted as TabataProgramState
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
125
src/shared/stores/workoutProgramStore.ts
Normal file
125
src/shared/stores/workoutProgramStore.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Workout Program Store
|
||||
* Tracks completion of body-zone workout programs
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import type { WorkoutProgram } from '../types/workoutProgram'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
interface ProgramCompletion {
|
||||
completedAt: string
|
||||
tabatasCompleted: number[]
|
||||
}
|
||||
|
||||
interface WorkoutProgramState {
|
||||
// Completion tracking
|
||||
completions: Record<string, ProgramCompletion>
|
||||
|
||||
// Actions
|
||||
completeProgram: (programId: string, tabataPosition?: number) => void
|
||||
resetProgram: (programId: string) => void
|
||||
|
||||
// Getters
|
||||
isProgramCompleted: (programId: string) => boolean
|
||||
getCompletedCount: () => number
|
||||
getRecommendedNext: (programs: WorkoutProgram[]) => WorkoutProgram | null
|
||||
getTabatasCompleted: (programId: string) => number[]
|
||||
}
|
||||
|
||||
// ─── Store ──────────────────────────────────────────────────────
|
||||
|
||||
export const useWorkoutProgramStore = create<WorkoutProgramState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
completions: {},
|
||||
|
||||
completeProgram: (programId, tabataPosition) => {
|
||||
set(state => {
|
||||
const existing = state.completions[programId]
|
||||
if (tabataPosition !== undefined) {
|
||||
// Mark specific tabata as completed
|
||||
const tabatasCompleted = existing
|
||||
? [...existing.tabatasCompleted.filter(t => t !== tabataPosition), tabataPosition]
|
||||
: [tabataPosition]
|
||||
const isDone = tabatasCompleted.length >= 3
|
||||
return {
|
||||
completions: {
|
||||
...state.completions,
|
||||
[programId]: {
|
||||
completedAt: isDone ? new Date().toISOString() : existing?.completedAt ?? '',
|
||||
tabatasCompleted,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
// Mark entire program as completed
|
||||
return {
|
||||
completions: {
|
||||
...state.completions,
|
||||
[programId]: {
|
||||
completedAt: new Date().toISOString(),
|
||||
tabatasCompleted: [1, 2, 3],
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
resetProgram: (programId) => {
|
||||
set(state => {
|
||||
const { [programId]: _, ...rest } = state.completions
|
||||
return { completions: rest }
|
||||
})
|
||||
},
|
||||
|
||||
isProgramCompleted: (programId) => {
|
||||
const completion = get().completions[programId]
|
||||
return !!completion && completion.tabatasCompleted.length >= 3
|
||||
},
|
||||
|
||||
getCompletedCount: () => {
|
||||
return Object.values(get().completions).filter(
|
||||
c => c.tabatasCompleted.length >= 3,
|
||||
).length
|
||||
},
|
||||
|
||||
getRecommendedNext: (programs) => {
|
||||
const { completions } = get()
|
||||
// Find first incomplete program, prioritizing Beginner → Intermediate → Advanced
|
||||
const levelOrder = { Beginner: 0, Intermediate: 1, Advanced: 2 }
|
||||
const sorted = [...programs].sort(
|
||||
(a, b) => levelOrder[a.level] - levelOrder[b.level],
|
||||
)
|
||||
return (
|
||||
sorted.find(p => {
|
||||
const c = completions[p.id]
|
||||
return !c || c.tabatasCompleted.length < 3
|
||||
}) ?? null
|
||||
)
|
||||
},
|
||||
|
||||
getTabatasCompleted: (programId) => {
|
||||
return get().completions[programId]?.tabatasCompleted ?? []
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'tabatafit-workout-program-storage',
|
||||
storage: {
|
||||
getItem: async (name) => {
|
||||
const value = await AsyncStorage.getItem(name)
|
||||
return value ? JSON.parse(value) : null
|
||||
},
|
||||
setItem: async (name, value) => {
|
||||
await AsyncStorage.setItem(name, JSON.stringify(value))
|
||||
},
|
||||
removeItem: async (name) => {
|
||||
await AsyncStorage.removeItem(name)
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
11
src/shared/supabase/CLAUDE.md
Normal file
11
src/shared/supabase/CLAUDE.md
Normal file
@@ -0,0 +1,11 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 13, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6267 | 10:51 PM | 🟣 | Database types added to mobile client for YouTube music integration | ~407 |
|
||||
</claude-mem-context>
|
||||
@@ -3,6 +3,8 @@
|
||||
* Initialize Supabase client with environment variables
|
||||
*/
|
||||
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import type { Database } from './database.types'
|
||||
|
||||
@@ -10,7 +12,7 @@ const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL
|
||||
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
console.warn(
|
||||
logger.warn(
|
||||
'Supabase credentials not found. Using mock data. ' +
|
||||
'Please set EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY in your .env file'
|
||||
)
|
||||
|
||||
@@ -267,60 +267,129 @@ export interface Database {
|
||||
sort_order?: number
|
||||
}
|
||||
}
|
||||
programs: {
|
||||
workout_programs: {
|
||||
Row: {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
weeks: number
|
||||
workouts_per_week: number
|
||||
description: string | null
|
||||
body_zone: 'upper-body' | 'lower-body' | 'full-body'
|
||||
level: 'Beginner' | 'Intermediate' | 'Advanced'
|
||||
is_free: boolean
|
||||
music_vibe: 'electronic' | 'hip-hop' | 'pop' | 'rock' | 'chill'
|
||||
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
|
||||
weeks: number
|
||||
workouts_per_week: number
|
||||
level: 'Beginner' | 'Intermediate' | 'Advanced'
|
||||
description?: string | null
|
||||
body_zone: 'upper-body' | 'lower-body' | 'full-body'
|
||||
level: 'Beginner' | 'Intermediate' | 'Advanced'
|
||||
is_free?: boolean
|
||||
music_vibe?: 'electronic' | 'hip-hop' | 'pop' | 'rock' | 'chill'
|
||||
estimated_duration?: number
|
||||
estimated_calories: number
|
||||
icon?: string | null
|
||||
accent_color?: string | null
|
||||
sort_order?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
title?: string
|
||||
description?: string
|
||||
weeks?: number
|
||||
workouts_per_week?: number
|
||||
description?: string | null
|
||||
body_zone?: 'upper-body' | 'lower-body' | 'full-body'
|
||||
level?: 'Beginner' | 'Intermediate' | 'Advanced'
|
||||
is_free?: boolean
|
||||
music_vibe?: 'electronic' | 'hip-hop' | 'pop' | 'rock' | 'chill'
|
||||
estimated_duration?: number
|
||||
estimated_calories?: number
|
||||
icon?: string | null
|
||||
accent_color?: string | null
|
||||
sort_order?: number
|
||||
updated_at?: string
|
||||
}
|
||||
}
|
||||
program_workouts: {
|
||||
program_tabatas: {
|
||||
Row: {
|
||||
id: string
|
||||
program_id: string
|
||||
workout_id: string
|
||||
week_number: number
|
||||
day_number: number
|
||||
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
|
||||
rounds: number
|
||||
work_time: number
|
||||
rest_time: number
|
||||
created_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
program_id: string
|
||||
workout_id: string
|
||||
week_number: number
|
||||
day_number: number
|
||||
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
|
||||
rounds?: number
|
||||
work_time?: number
|
||||
rest_time?: number
|
||||
created_at?: string
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
program_id?: string
|
||||
workout_id?: string
|
||||
week_number?: number
|
||||
day_number?: number
|
||||
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
|
||||
rounds?: number
|
||||
work_time?: number
|
||||
rest_time?: number
|
||||
}
|
||||
}
|
||||
achievements: {
|
||||
@@ -376,6 +445,61 @@ export interface Database {
|
||||
last_login?: string | null
|
||||
}
|
||||
}
|
||||
download_jobs: {
|
||||
Row: {
|
||||
id: string
|
||||
playlist_url: string
|
||||
playlist_title: string | null
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
total_items: number
|
||||
completed_items: number
|
||||
failed_items: number
|
||||
created_by: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
playlist_url: string
|
||||
playlist_title?: string | null
|
||||
status?: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
total_items?: number
|
||||
completed_items?: number
|
||||
failed_items?: number
|
||||
created_by: string
|
||||
}
|
||||
Update: Partial<Omit<Database['public']['Tables']['download_jobs']['Insert'], 'id'>>
|
||||
}
|
||||
download_items: {
|
||||
Row: {
|
||||
id: string
|
||||
job_id: string
|
||||
video_id: string
|
||||
title: string | null
|
||||
duration_seconds: number | null
|
||||
thumbnail_url: string | null
|
||||
status: 'pending' | 'downloading' | 'completed' | 'failed'
|
||||
storage_path: string | null
|
||||
public_url: string | null
|
||||
error_message: string | null
|
||||
genre: 'edm' | 'hip-hop' | 'pop' | 'rock' | 'latin' | 'house' | 'drum-and-bass' | 'dubstep' | 'r-and-b' | 'country' | 'metal' | 'ambient' | null
|
||||
created_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
job_id: string
|
||||
video_id: string
|
||||
title?: string | null
|
||||
duration_seconds?: number | null
|
||||
thumbnail_url?: string | null
|
||||
status?: 'pending' | 'downloading' | 'completed' | 'failed'
|
||||
storage_path?: string | null
|
||||
public_url?: string | null
|
||||
error_message?: string | null
|
||||
genre?: 'edm' | 'hip-hop' | 'pop' | 'rock' | 'latin' | 'house' | 'drum-and-bass' | 'dubstep' | 'r-and-b' | 'country' | 'metal' | 'ambient' | null
|
||||
}
|
||||
Update: Partial<Omit<Database['public']['Tables']['download_items']['Insert'], 'id'>>
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
/**
|
||||
* Theme context + provider
|
||||
* Switches palette based on system color scheme
|
||||
* Dark mode only — Dark Medical design system
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useMemo } from 'react'
|
||||
import { useColorScheme } from 'react-native'
|
||||
import { createContext, useContext } from 'react'
|
||||
import { darkColors } from './colors.dark'
|
||||
import { lightColors } from './colors.light'
|
||||
import type { ThemeColors } from './types'
|
||||
|
||||
const ThemeContext = createContext<ThemeColors>(darkColors)
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const scheme = useColorScheme()
|
||||
const colors = useMemo(
|
||||
() => (scheme === 'light' ? lightColors : darkColors),
|
||||
[scheme],
|
||||
)
|
||||
return (
|
||||
<ThemeContext.Provider value={colors}>{children}</ThemeContext.Provider>
|
||||
<ThemeContext.Provider value={darkColors}>{children}</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,123 +1,53 @@
|
||||
/**
|
||||
* Dark theme palette
|
||||
* Values extracted from constants/colors.ts — same values, ThemeColors shape
|
||||
* Dark Medical theme palette
|
||||
* Navy backgrounds, green actions, no glass, no shadows
|
||||
*/
|
||||
|
||||
import { BRAND } from '../constants/colors'
|
||||
import type { ThemeColors } from './types'
|
||||
|
||||
export const darkColors: ThemeColors = {
|
||||
bg: {
|
||||
base: '#000000',
|
||||
surface: '#1C1C1E',
|
||||
elevated: '#2C2C2E',
|
||||
overlay1: 'rgba(255, 255, 255, 0.05)',
|
||||
overlay2: 'rgba(255, 255, 255, 0.08)',
|
||||
overlay3: 'rgba(255, 255, 255, 0.12)',
|
||||
scrim: 'rgba(0, 0, 0, 0.6)',
|
||||
base: '#0D1B2A', // navy-900
|
||||
surface: '#112240', // navy-800
|
||||
elevated: '#1A3050', // navy-700
|
||||
overlay1: 'rgba(168,178,216,0.06)',
|
||||
overlay2: 'rgba(168,178,216,0.10)',
|
||||
overlay3: 'rgba(168,178,216,0.15)',
|
||||
scrim: 'rgba(0,0,0,0.6)',
|
||||
},
|
||||
text: {
|
||||
primary: '#FFFFFF',
|
||||
secondary: '#EBEBF5',
|
||||
tertiary: 'rgba(235, 235, 245, 0.6)',
|
||||
muted: 'rgba(235, 235, 245, 0.5)',
|
||||
hint: 'rgba(235, 235, 245, 0.3)',
|
||||
primary: '#E6F1FF', // white-100
|
||||
secondary: '#A8B2D8', // slate-300
|
||||
tertiary: '#8892B0', // slate-400
|
||||
muted: '#8892B0',
|
||||
hint: '#8892B0',
|
||||
disabled: '#3A3A3C',
|
||||
},
|
||||
glass: {
|
||||
base: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
surface: {
|
||||
default: {
|
||||
backgroundColor: '#112240',
|
||||
borderColor: 'rgba(168,178,216,0.15)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
elevated: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.15)',
|
||||
accent: {
|
||||
backgroundColor: 'rgba(0,200,150,0.05)',
|
||||
borderColor: 'rgba(0,200,150,0.35)',
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
tip: {
|
||||
backgroundColor: 'rgba(255,138,92,0.12)',
|
||||
borderColor: '#FF8A5C',
|
||||
borderWidth: 1,
|
||||
},
|
||||
inset: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.25)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
tinted: {
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.12)',
|
||||
borderColor: 'rgba(255, 107, 53, 0.25)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
successTinted: {
|
||||
backgroundColor: 'rgba(48, 209, 88, 0.12)',
|
||||
borderColor: 'rgba(48, 209, 88, 0.25)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
blurTint: 'dark',
|
||||
blurLight: 20,
|
||||
blurMedium: 40,
|
||||
blurHeavy: 60,
|
||||
blurUltra: 80,
|
||||
},
|
||||
shadow: {
|
||||
sm: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
md: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 24,
|
||||
elevation: 8,
|
||||
},
|
||||
lg: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 40,
|
||||
elevation: 12,
|
||||
},
|
||||
xl: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 16 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 48,
|
||||
elevation: 16,
|
||||
},
|
||||
BRAND_GLOW: {
|
||||
shadowColor: BRAND.PRIMARY,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 20,
|
||||
elevation: 10,
|
||||
},
|
||||
BRAND_GLOW_SUBTLE: {
|
||||
shadowColor: BRAND.PRIMARY,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 6,
|
||||
},
|
||||
LIQUID_GLOW: {
|
||||
shadowColor: BRAND.PRIMARY,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 30,
|
||||
elevation: 15,
|
||||
},
|
||||
},
|
||||
border: {
|
||||
glass: 'rgba(255, 255, 255, 0.1)',
|
||||
glassLight: 'rgba(255, 255, 255, 0.05)',
|
||||
glassStrong: 'rgba(255, 255, 255, 0.2)',
|
||||
brand: 'rgba(255, 107, 53, 0.3)',
|
||||
success: 'rgba(48, 209, 88, 0.3)',
|
||||
dim: 'rgba(168,178,216,0.15)',
|
||||
hover: 'rgba(168,178,216,0.25)',
|
||||
brand: 'rgba(0,200,150,0.35)',
|
||||
},
|
||||
gradients: {
|
||||
videoOverlay: ['transparent', 'rgba(0, 0, 0, 0.8)'],
|
||||
videoTop: ['rgba(0, 0, 0, 0.5)', 'transparent'],
|
||||
glassShimmer: ['rgba(255,255,255,0.1)', 'rgba(255,255,255,0.05)', 'rgba(255,255,255,0.1)'],
|
||||
videoOverlay: ['transparent', 'rgba(0,0,0,0.8)'],
|
||||
videoTop: ['rgba(0,0,0,0.5)', 'transparent'],
|
||||
},
|
||||
colorScheme: 'dark',
|
||||
statusBarStyle: 'light',
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* Light theme palette
|
||||
* Apple HIG-aligned light mode values
|
||||
*/
|
||||
|
||||
import { BRAND } from '../constants/colors'
|
||||
import type { ThemeColors } from './types'
|
||||
|
||||
export const lightColors: ThemeColors = {
|
||||
bg: {
|
||||
base: '#FFFFFF',
|
||||
surface: '#F2F2F7',
|
||||
elevated: '#FFFFFF',
|
||||
overlay1: 'rgba(0, 0, 0, 0.03)',
|
||||
overlay2: 'rgba(0, 0, 0, 0.05)',
|
||||
overlay3: 'rgba(0, 0, 0, 0.08)',
|
||||
scrim: 'rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
text: {
|
||||
primary: '#000000',
|
||||
secondary: '#3C3C43',
|
||||
tertiary: 'rgba(60, 60, 67, 0.6)',
|
||||
muted: 'rgba(60, 60, 67, 0.5)',
|
||||
hint: 'rgba(60, 60, 67, 0.3)',
|
||||
disabled: '#C7C7CC',
|
||||
},
|
||||
glass: {
|
||||
base: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.03)',
|
||||
borderColor: 'rgba(0, 0, 0, 0.08)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
elevated: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
borderColor: 'rgba(0, 0, 0, 0.1)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
inset: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.06)',
|
||||
borderColor: 'rgba(0, 0, 0, 0.04)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
tinted: {
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.08)',
|
||||
borderColor: 'rgba(255, 107, 53, 0.2)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
successTinted: {
|
||||
backgroundColor: 'rgba(48, 209, 88, 0.08)',
|
||||
borderColor: 'rgba(48, 209, 88, 0.2)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
blurTint: 'light',
|
||||
blurLight: 20,
|
||||
blurMedium: 40,
|
||||
blurHeavy: 60,
|
||||
blurUltra: 80,
|
||||
},
|
||||
shadow: {
|
||||
sm: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
md: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 16,
|
||||
elevation: 4,
|
||||
},
|
||||
lg: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 24,
|
||||
elevation: 8,
|
||||
},
|
||||
xl: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
shadowOpacity: 0.18,
|
||||
shadowRadius: 32,
|
||||
elevation: 12,
|
||||
},
|
||||
BRAND_GLOW: {
|
||||
shadowColor: BRAND.PRIMARY,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 16,
|
||||
elevation: 8,
|
||||
},
|
||||
BRAND_GLOW_SUBTLE: {
|
||||
shadowColor: BRAND.PRIMARY,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 10,
|
||||
elevation: 4,
|
||||
},
|
||||
LIQUID_GLOW: {
|
||||
shadowColor: BRAND.PRIMARY,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 24,
|
||||
elevation: 12,
|
||||
},
|
||||
},
|
||||
border: {
|
||||
glass: 'rgba(0, 0, 0, 0.1)',
|
||||
glassLight: 'rgba(0, 0, 0, 0.05)',
|
||||
glassStrong: 'rgba(0, 0, 0, 0.15)',
|
||||
brand: 'rgba(255, 107, 53, 0.3)',
|
||||
success: 'rgba(48, 209, 88, 0.3)',
|
||||
},
|
||||
gradients: {
|
||||
videoOverlay: ['transparent', 'rgba(0, 0, 0, 0.6)'],
|
||||
videoTop: ['rgba(0, 0, 0, 0.3)', 'transparent'],
|
||||
glassShimmer: ['rgba(0,0,0,0.05)', 'rgba(0,0,0,0.02)', 'rgba(0,0,0,0.05)'],
|
||||
},
|
||||
colorScheme: 'light',
|
||||
statusBarStyle: 'dark',
|
||||
}
|
||||
@@ -1,22 +1,14 @@
|
||||
/**
|
||||
* Theme type definitions
|
||||
* Same shape for light and dark palettes
|
||||
* Dark Medical — no glass, no shadows
|
||||
*/
|
||||
|
||||
export interface GlassStyle {
|
||||
export interface SurfaceStyle {
|
||||
backgroundColor: string
|
||||
borderColor: string
|
||||
borderWidth: number
|
||||
}
|
||||
|
||||
export interface ShadowStyle {
|
||||
shadowColor: string
|
||||
shadowOffset: { width: number; height: number }
|
||||
shadowOpacity: number
|
||||
shadowRadius: number
|
||||
elevation: number
|
||||
}
|
||||
|
||||
export interface ThemeColors {
|
||||
bg: {
|
||||
base: string
|
||||
@@ -35,39 +27,20 @@ export interface ThemeColors {
|
||||
hint: string
|
||||
disabled: string
|
||||
}
|
||||
glass: {
|
||||
base: GlassStyle
|
||||
elevated: GlassStyle
|
||||
inset: GlassStyle
|
||||
tinted: GlassStyle
|
||||
successTinted: GlassStyle
|
||||
blurTint: 'dark' | 'light'
|
||||
blurLight: number
|
||||
blurMedium: number
|
||||
blurHeavy: number
|
||||
blurUltra: number
|
||||
}
|
||||
shadow: {
|
||||
sm: ShadowStyle
|
||||
md: ShadowStyle
|
||||
lg: ShadowStyle
|
||||
xl: ShadowStyle
|
||||
BRAND_GLOW: ShadowStyle
|
||||
BRAND_GLOW_SUBTLE: ShadowStyle
|
||||
LIQUID_GLOW: ShadowStyle
|
||||
surface: {
|
||||
default: SurfaceStyle
|
||||
accent: SurfaceStyle
|
||||
tip: SurfaceStyle
|
||||
}
|
||||
border: {
|
||||
glass: string
|
||||
glassLight: string
|
||||
glassStrong: string
|
||||
dim: string
|
||||
hover: string
|
||||
brand: string
|
||||
success: string
|
||||
}
|
||||
gradients: {
|
||||
videoOverlay: readonly string[]
|
||||
videoTop: readonly string[]
|
||||
glassShimmer: readonly string[]
|
||||
}
|
||||
colorScheme: 'dark' | 'light'
|
||||
statusBarStyle: 'light' | 'dark'
|
||||
colorScheme: 'dark'
|
||||
statusBarStyle: 'light'
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Feb 20, 2026
|
||||
### Apr 9, 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 |
|
||||
| #5980 | 9:54 AM | 🟣 | Bureau Tabata Kine program implemented with 13 office-friendly sessions | ~541 |
|
||||
| #5979 | 9:53 AM | 🟣 | Bureau program encoded into Tabata Kine system | ~432 |
|
||||
</claude-mem-context>
|
||||
@@ -1,8 +1,173 @@
|
||||
import type { MusicVibe } from './workout'
|
||||
|
||||
/**
|
||||
* TabataFit Program Types
|
||||
* Physiotherapist-designed 3-program progressive system
|
||||
* Tabata Program Types
|
||||
* Physiotherapist-designed 4-program progressive system
|
||||
*
|
||||
* Model: Programme → Semaine → Séance → Bloc → Exercice
|
||||
* Each block alternates between odd/even exercises on tabata rounds.
|
||||
*/
|
||||
|
||||
// ─── Tabata Types ─────────────────────────────────────────────
|
||||
|
||||
export type TabataProgramId = 'debutant' | 'intermediaire' | 'avance' | 'bureau'
|
||||
|
||||
export type ProgramTier = 'free' | 'premium'
|
||||
|
||||
/** A single exercise with tabata metadata */
|
||||
export interface TabataExercise {
|
||||
name: string
|
||||
nameEn: string
|
||||
/** Physiotherapist tip (the 📋 text from the guide) */
|
||||
conseil: string
|
||||
conseilEn: string
|
||||
/** Easier alternative */
|
||||
modification?: string
|
||||
modificationEn?: string
|
||||
/** Harder alternative */
|
||||
progression?: string
|
||||
progressionEn?: string
|
||||
}
|
||||
|
||||
/** A tabata block alternates two exercises on odd/even rounds */
|
||||
export interface TabataBlock {
|
||||
id: string
|
||||
/** Rounds 1, 3, 5, 7 */
|
||||
oddExercise: TabataExercise
|
||||
/** Rounds 2, 4, 6, 8 */
|
||||
evenExercise: TabataExercise
|
||||
/** Standard tabata: 8 rounds per block */
|
||||
rounds: number
|
||||
/** Work interval in seconds (typically 20) */
|
||||
workTime: number
|
||||
/** Rest interval in seconds (typically 10) */
|
||||
restTime: number
|
||||
}
|
||||
|
||||
/** A timed movement for warmup or cooldown */
|
||||
export interface TimedMovement {
|
||||
name: string
|
||||
nameEn: string
|
||||
/** Duration in seconds */
|
||||
duration: number
|
||||
}
|
||||
|
||||
export interface WarmupPhase {
|
||||
movements: TimedMovement[]
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
export interface CooldownPhase {
|
||||
movements: TimedMovement[]
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
/** A session = warmup + blocks + cooldown */
|
||||
export interface TabataSession {
|
||||
id: string // e.g. 'deb-w1-s1'
|
||||
week: number
|
||||
/** Order within the week (1-based) */
|
||||
order: number
|
||||
title: string
|
||||
titleEn: string
|
||||
description: string
|
||||
descriptionEn: string
|
||||
focus: string[]
|
||||
focusEn: string[]
|
||||
warmup: WarmupPhase
|
||||
blocks: TabataBlock[]
|
||||
cooldown: CooldownPhase
|
||||
equipment: string[]
|
||||
/** Sum of all block rounds */
|
||||
totalRounds: number
|
||||
/** Estimated total duration in minutes including warmup/cooldown */
|
||||
totalDuration: number
|
||||
/** Estimated calories */
|
||||
calories: number
|
||||
/** Music vibe for this session (from workout program) */
|
||||
musicVibe?: MusicVibe
|
||||
}
|
||||
|
||||
export interface TabataWeek {
|
||||
weekNumber: number
|
||||
title: string
|
||||
titleEn: string
|
||||
description: string
|
||||
descriptionEn: string
|
||||
focus: string
|
||||
focusEn: string
|
||||
/** Whether this is a deload week (reduced volume) */
|
||||
isDeload: boolean
|
||||
sessions: TabataSession[]
|
||||
}
|
||||
|
||||
export interface TabataProgram {
|
||||
id: TabataProgramId
|
||||
title: string
|
||||
titleEn: string
|
||||
description: string
|
||||
descriptionEn: string
|
||||
tier: ProgramTier
|
||||
/** Accent color for UI */
|
||||
accentColor: string
|
||||
/** Icon name for display */
|
||||
icon: string
|
||||
durationWeeks: number
|
||||
/** Sessions per week — may vary by week */
|
||||
sessionsPerWeek: number
|
||||
totalSessions: number
|
||||
equipment: {
|
||||
required: string[]
|
||||
optional: string[]
|
||||
}
|
||||
focusAreas: string[]
|
||||
focusAreasEn: string[]
|
||||
/** Program rules/principles displayed to user */
|
||||
principles: string[]
|
||||
principlesEn: string[]
|
||||
/** Criteria to pass before moving to next program */
|
||||
completionCriteria: string[]
|
||||
completionCriteriaEn: string[]
|
||||
/** Recommended next program */
|
||||
nextProgramId?: TabataProgramId
|
||||
weeks: TabataWeek[]
|
||||
}
|
||||
|
||||
/** Tabata-specific achievements */
|
||||
export interface TabataAchievement {
|
||||
id: string
|
||||
title: string
|
||||
titleEn: string
|
||||
description: string
|
||||
descriptionEn: string
|
||||
icon: string
|
||||
requirement: number
|
||||
type: 'sessions' | 'streak' | 'weeks' | 'programs' | 'calories'
|
||||
}
|
||||
|
||||
/** Progress tracking for tabata programs */
|
||||
export interface TabataProgramProgress {
|
||||
programId: TabataProgramId
|
||||
currentWeek: number
|
||||
currentSessionIndex: number
|
||||
completedSessionIds: string[]
|
||||
isProgramCompleted: boolean
|
||||
startDate?: string
|
||||
lastSessionDate?: string
|
||||
}
|
||||
|
||||
// ─── Timer Phases ─────────────────────────────────────────────
|
||||
|
||||
export type TabataTimerPhase =
|
||||
| 'WARMUP'
|
||||
| 'WORK'
|
||||
| 'REST'
|
||||
| 'INTER_BLOCK_REST'
|
||||
| 'COOLDOWN'
|
||||
| 'COMPLETE'
|
||||
|
||||
// ─── Legacy Types (kept for backward compatibility) ───────────
|
||||
|
||||
export type ProgramId = 'upper-body' | 'lower-body' | 'full-body'
|
||||
|
||||
export type WeekNumber = 1 | 2 | 3 | 4
|
||||
@@ -10,10 +175,10 @@ export type WeekNumber = 1 | 2 | 3 | 4
|
||||
export type ProgramWorkout = {
|
||||
id: string
|
||||
week: WeekNumber
|
||||
order: number // 1-5 within the week
|
||||
order: number
|
||||
title: string
|
||||
description: string
|
||||
duration: 4 // minutes - all workouts are 4 min
|
||||
duration: 4
|
||||
exercises: ProgramExercise[]
|
||||
equipment: string[]
|
||||
focus: string[]
|
||||
@@ -22,10 +187,10 @@ export type ProgramWorkout = {
|
||||
|
||||
export type ProgramExercise = {
|
||||
name: string
|
||||
duration: 20 // seconds - fixed for Tabata
|
||||
reps?: string // optional rep guidance (e.g., "8-10 reps")
|
||||
modification?: string // easier alternative
|
||||
progression?: string // harder alternative
|
||||
duration: 20
|
||||
reps?: string
|
||||
modification?: string
|
||||
progression?: string
|
||||
}
|
||||
|
||||
export interface Week {
|
||||
@@ -54,7 +219,7 @@ export interface Program {
|
||||
export interface ProgramProgress {
|
||||
programId: ProgramId
|
||||
currentWeek: WeekNumber
|
||||
currentWorkoutIndex: number // 0-4 within current week
|
||||
currentWorkoutIndex: number
|
||||
completedWorkoutIds: string[]
|
||||
isProgramCompleted: boolean
|
||||
startDate?: string
|
||||
@@ -64,7 +229,7 @@ export interface ProgramProgress {
|
||||
export interface AssessmentExercise {
|
||||
name: string
|
||||
duration: 20
|
||||
purpose: string // what we're checking
|
||||
purpose: string
|
||||
}
|
||||
|
||||
export interface Assessment {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
export interface Trainer {
|
||||
id: string
|
||||
name: string
|
||||
gender?: 'male' | 'female'
|
||||
specialty: string
|
||||
color: string
|
||||
avatarUrl?: string
|
||||
|
||||
137
src/shared/types/workoutProgram.ts
Normal file
137
src/shared/types/workoutProgram.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { MusicVibe } from './workout'
|
||||
|
||||
/**
|
||||
* Workout Program Types
|
||||
* Body Zone + Difficulty model
|
||||
* Program = 3 Tabatas, each Tabata = 2 exercises × 8 rounds
|
||||
*/
|
||||
|
||||
// ─── Enums ──────────────────────────────────────────────────────
|
||||
|
||||
export type BodyZone = 'upper-body' | 'lower-body' | 'full-body'
|
||||
|
||||
export type ProgramLevel = 'Beginner' | 'Intermediate' | 'Advanced'
|
||||
|
||||
// ─── Exercise ───────────────────────────────────────────────────
|
||||
|
||||
export interface ProgramExercise {
|
||||
name: string
|
||||
nameEn: string
|
||||
tip?: string
|
||||
tipEn?: string
|
||||
modification?: string
|
||||
modificationEn?: string
|
||||
progression?: string
|
||||
progressionEn?: string
|
||||
}
|
||||
|
||||
// ─── Tabata ─────────────────────────────────────────────────────
|
||||
|
||||
export interface WorkoutTabata {
|
||||
id: string
|
||||
position: 1 | 2 | 3
|
||||
exercise1: ProgramExercise
|
||||
exercise2: ProgramExercise
|
||||
rounds: number
|
||||
workTime: number
|
||||
restTime: number
|
||||
}
|
||||
|
||||
// ─── Program ────────────────────────────────────────────────────
|
||||
|
||||
export interface WorkoutProgram {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
bodyZone: BodyZone
|
||||
level: ProgramLevel
|
||||
isFree: boolean
|
||||
musicVibe: MusicVibe
|
||||
estimatedDuration: number
|
||||
estimatedCalories: number
|
||||
icon: string | null
|
||||
accentColor: string | null
|
||||
sortOrder: number
|
||||
tabatas: WorkoutTabata[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// ─── Supabase Row Types ─────────────────────────────────────────
|
||||
|
||||
export interface WorkoutProgramRow {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
body_zone: BodyZone
|
||||
level: ProgramLevel
|
||||
is_free: boolean
|
||||
music_vibe: MusicVibe
|
||||
estimated_duration: number
|
||||
estimated_calories: number
|
||||
icon: string | null
|
||||
accent_color: string | null
|
||||
sort_order: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface WorkoutTabataRow {
|
||||
id: string
|
||||
program_id: string
|
||||
position: 1 | 2 | 3
|
||||
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
|
||||
rounds: number
|
||||
work_time: number
|
||||
rest_time: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// ─── Display Metadata ───────────────────────────────────────────
|
||||
|
||||
export const BODY_ZONE_META: Record<BodyZone, {
|
||||
label: string
|
||||
labelEn: string
|
||||
icon: string
|
||||
color: string
|
||||
}> = {
|
||||
'upper-body': {
|
||||
label: 'Haut du corps',
|
||||
labelEn: 'Upper Body',
|
||||
icon: 'figure.strengthtraining.traditional',
|
||||
color: '#4A90D9',
|
||||
},
|
||||
'lower-body': {
|
||||
label: 'Bas du corps',
|
||||
labelEn: 'Lower Body',
|
||||
icon: 'figure.run',
|
||||
color: '#9B59B6',
|
||||
},
|
||||
'full-body': {
|
||||
label: 'Corps entier',
|
||||
labelEn: 'Full Body',
|
||||
icon: 'figure.cooldown',
|
||||
color: '#00C896',
|
||||
},
|
||||
}
|
||||
|
||||
export const LEVEL_META: Record<ProgramLevel, { label: string; labelEn: string; color: string }> = {
|
||||
Beginner: { label: 'Débutant', labelEn: 'Beginner', color: '#30D158' },
|
||||
Intermediate: { label: 'Intermédiaire', labelEn: 'Intermediate', color: '#FF9500' },
|
||||
Advanced: { label: 'Avancé', labelEn: 'Advanced', color: '#FF453A' },
|
||||
}
|
||||
@@ -3,10 +3,5 @@
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Feb 20, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5228 | 1:25 PM | 🔄 | Removed v1 features and old scaffolding from TabataFit codebase | ~591 |
|
||||
| #5224 | 1:24 PM | ✅ | Stage v1.1 files prepared for git commit - SwiftUI Button refactoring complete | ~434 |
|
||||
*No recent activity*
|
||||
</claude-mem-context>
|
||||
18
src/shared/utils/color.ts
Normal file
18
src/shared/utils/color.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Color utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Converts a hex color + opacity (0-1) to an rgba string.
|
||||
* Handles 3-digit and 6-digit hex colors.
|
||||
*/
|
||||
export function withOpacity(hex: string, opacity: number): string {
|
||||
const clean = hex.replace('#', '')
|
||||
const full = clean.length === 3
|
||||
? clean.split('').map(c => c + c).join('')
|
||||
: clean
|
||||
const r = parseInt(full.substring(0, 2), 16)
|
||||
const g = parseInt(full.substring(2, 4), 16)
|
||||
const b = parseInt(full.substring(4, 6), 16)
|
||||
return `rgba(${r},${g},${b},${opacity})`
|
||||
}
|
||||
17
src/shared/utils/logger.ts
Normal file
17
src/shared/utils/logger.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Logger utility with __DEV__ guard
|
||||
* In production builds, all log/warn/debug calls are no-ops.
|
||||
* Errors are always logged (they indicate real problems).
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
function noop(..._args: unknown[]): void {}
|
||||
|
||||
export const logger = {
|
||||
log: __DEV__ ? console.log.bind(console) : noop,
|
||||
warn: __DEV__ ? console.warn.bind(console) : noop,
|
||||
error: console.error.bind(console),
|
||||
debug: __DEV__ ? console.debug.bind(console) : noop,
|
||||
info: __DEV__ ? console.info.bind(console) : noop,
|
||||
}
|
||||
Reference in New Issue
Block a user