feat: data layer with types, 50 workouts, and Zustand stores

Types: Workout, Exercise, Trainer, UserProfile, UserSettings,
WorkoutResult, Achievement, Collection, Program.

Mock data: 50 workouts across 5 categories (full-body, core,
upper-body, lower-body, cardio), 4 durations (4/8/12/20 min),
3 levels, 5 trainers. Plus 6 collections, 3 programs, and
8 achievements.

Zustand stores with AsyncStorage persistence:
- userStore: profile, settings (haptics, sound, voice, reminders)
- activityStore: workout history, streak tracking
- playerStore: ephemeral timer state (not persisted)

Helper lookups: getWorkoutById, getWorkoutsByCategory,
getTrainerById, getPopularWorkouts, getCollectionWorkouts, etc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Millian Lamiaux
2026-02-20 13:23:32 +01:00
parent 511e207762
commit 5477ecb852
15 changed files with 1799 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
/**
* TabataFit Achievement Definitions
*/
import { Achievement } from '../types'
export const ACHIEVEMENTS: Achievement[] = [
{
id: 'first-burn',
title: 'First Burn',
description: 'Complete your first workout',
icon: 'flame',
requirement: 1,
type: 'workouts',
},
{
id: 'week-warrior',
title: 'Week Warrior',
description: '7 day streak',
icon: 'calendar',
requirement: 7,
type: 'streak',
},
{
id: 'century-club',
title: 'Century Club',
description: 'Burn 100 calories total',
icon: 'flame',
requirement: 100,
type: 'calories',
},
{
id: 'iron-will',
title: 'Iron Will',
description: 'Complete 10 workouts',
icon: 'trophy',
requirement: 10,
type: 'workouts',
},
{
id: 'tabata-master',
title: 'Tabata Master',
description: 'Complete 50 workouts',
icon: 'star',
requirement: 50,
type: 'workouts',
},
{
id: 'marathon-burner',
title: 'Marathon Burner',
description: 'Exercise for 100 minutes total',
icon: 'time',
requirement: 100,
type: 'minutes',
},
{
id: 'unstoppable',
title: 'Unstoppable',
description: '30 day streak',
icon: 'rocket',
requirement: 30,
type: 'streak',
},
{
id: 'calorie-crusher',
title: 'Calorie Crusher',
description: 'Burn 1000 calories total',
icon: 'flame',
requirement: 1000,
type: 'calories',
},
]

View File

@@ -0,0 +1,83 @@
/**
* TabataFit Collections & Programs
*/
import { Collection, Program } from '../types'
export const COLLECTIONS: Collection[] = [
{
id: 'morning-energizer',
title: 'Morning Energizer',
description: 'Start your day right',
icon: '🌅',
workoutIds: ['4', '6', '43', '47', '10'],
},
{
id: 'no-equipment',
title: 'No Equipment',
description: 'Workout anywhere',
icon: '💪',
workoutIds: ['1', '4', '6', '11', '13', '16', '17', '19', '23', '26', '31', '38', '42', '43', '45'],
},
{
id: '7-day-burn',
title: '7-Day Burn Challenge',
description: 'Transform in one week',
icon: '🔥',
workoutIds: ['1', '11', '31', '42', '6', '17', '23'],
gradient: ['#FF6B35', '#FF3B30'],
},
{
id: 'quick-intense',
title: 'Quick & Intense',
description: 'Max effort in 4 minutes',
icon: '⚡',
workoutIds: ['1', '11', '23', '35', '38', '42', '6', '17'],
},
{
id: 'core-focus',
title: 'Core Focus',
description: 'Build a solid foundation',
icon: '🎯',
workoutIds: ['11', '12', '13', '14', '16', '17'],
},
{
id: 'leg-day',
title: 'Leg Day',
description: 'Never skip leg day',
icon: '🦵',
workoutIds: ['31', '32', '33', '34', '35', '36', '37'],
},
]
export const PROGRAMS: Program[] = [
{
id: 'beginner-journey',
title: 'Beginner Journey',
description: 'Your first steps into Tabata fitness',
weeks: 2,
workoutsPerWeek: 3,
level: 'Beginner',
workoutIds: ['1', '13', '31', '43', '6', '16'],
},
{
id: 'strength-builder',
title: 'Strength Builder',
description: 'Progressive strength development',
weeks: 4,
workoutsPerWeek: 4,
level: 'Intermediate',
workoutIds: ['2', '21', '32', '9', '14', '25', '35', '29', '7', '23', '39', '44', '11', '42', '8', '27'],
},
{
id: 'fat-burn-protocol',
title: 'Fat Burn Protocol',
description: 'Maximum calorie burn program',
weeks: 6,
workoutsPerWeek: 5,
level: 'Advanced',
workoutIds: ['3', '41', '12', '34', '46', '5', '18', '27', '36', '50', '8', '15', '24', '38', '44', '20', '30', '40', '46', '50', '3', '41', '5', '8', '12', '15', '18', '24', '27', '34'],
},
]
export const FEATURED_COLLECTION_ID = '7-day-burn'

117
src/shared/data/index.ts Normal file
View File

@@ -0,0 +1,117 @@
/**
* TabataFit Data Layer
* Single source of truth + helper lookups
*/
import { WORKOUTS } from './workouts'
import { TRAINERS } from './trainers'
import { COLLECTIONS, PROGRAMS, FEATURED_COLLECTION_ID } from './collections'
import { ACHIEVEMENTS } from './achievements'
import type { WorkoutCategory, WorkoutLevel, WorkoutDuration } from '../types'
// Re-export raw data
export { WORKOUTS, TRAINERS, COLLECTIONS, PROGRAMS, ACHIEVEMENTS, FEATURED_COLLECTION_ID }
// ═══════════════════════════════════════════════════════════════════════════
// WORKOUT LOOKUPS
// ═══════════════════════════════════════════════════════════════════════════
export function getWorkoutById(id: string) {
return WORKOUTS.find(w => w.id === id)
}
export function getWorkoutsByCategory(category: WorkoutCategory) {
return WORKOUTS.filter(w => w.category === category)
}
export function getWorkoutsByTrainer(trainerId: string) {
return WORKOUTS.filter(w => w.trainerId === trainerId)
}
export function getWorkoutsByLevel(level: WorkoutLevel) {
return WORKOUTS.filter(w => w.level === level)
}
export function getWorkoutsByDuration(duration: WorkoutDuration) {
return WORKOUTS.filter(w => w.duration === duration)
}
export function getFeaturedWorkouts() {
return WORKOUTS.filter(w => w.isFeatured)
}
export function getPopularWorkouts(count = 8) {
// Simulate popularity — pick a diverse spread
return WORKOUTS.filter(w => ['1', '11', '21', '31', '41', '42', '2', '32'].includes(w.id)).slice(0, count)
}
// ═══════════════════════════════════════════════════════════════════════════
// TRAINER LOOKUPS
// ═══════════════════════════════════════════════════════════════════════════
export function getTrainerById(id: string) {
return TRAINERS.find(t => t.id === id)
}
export function getTrainerByName(name: string) {
return TRAINERS.find(t => t.name.toLowerCase() === name.toLowerCase())
}
// ═══════════════════════════════════════════════════════════════════════════
// COLLECTION LOOKUPS
// ═══════════════════════════════════════════════════════════════════════════
export function getCollectionById(id: string) {
return COLLECTIONS.find(c => c.id === id)
}
export function getCollectionWorkouts(collectionId: string) {
const collection = getCollectionById(collectionId)
if (!collection) return []
return collection.workoutIds.map(id => getWorkoutById(id)).filter(Boolean)
}
export function getFeaturedCollection() {
return getCollectionById(FEATURED_COLLECTION_ID)
}
// ═══════════════════════════════════════════════════════════════════════════
// PROGRAM LOOKUPS
// ═══════════════════════════════════════════════════════════════════════════
export function getProgramById(id: string) {
return PROGRAMS.find(p => p.id === id)
}
// ═══════════════════════════════════════════════════════════════════════════
// CATEGORY METADATA
// ═══════════════════════════════════════════════════════════════════════════
export const CATEGORIES: { id: WorkoutCategory | 'all'; label: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'full-body', label: 'Full Body' },
{ id: 'core', label: 'Core' },
{ id: 'upper-body', label: 'Upper Body' },
{ id: 'lower-body', label: 'Lower Body' },
{ id: 'cardio', label: 'Cardio' },
]
// SF Symbol icon map for collections
export const COLLECTION_ICONS: Record<string, string> = {
'morning-energizer': 'sunrise.fill',
'no-equipment': 'figure.strengthtraining.traditional',
'7-day-burn': 'flame.fill',
'quick-intense': 'bolt.fill',
'core-focus': 'target',
'leg-day': 'figure.walk',
}
// Collection color map
export const COLLECTION_COLORS: Record<string, string> = {
'morning-energizer': '#FFD60A',
'no-equipment': '#30D158',
'7-day-burn': '#FF3B30',
'quick-intense': '#FF3B30',
'core-focus': '#5AC8FA',
'leg-day': '#BF5AF2',
}

View File

@@ -0,0 +1,44 @@
/**
* TabataFit Trainer Data
* 5 trainers per PRD
*/
import { Trainer } from '../types'
export const TRAINERS: Trainer[] = [
{
id: 'emma',
name: 'Emma',
specialty: 'Full Body',
color: '#FF6B35',
workoutCount: 15,
},
{
id: 'jake',
name: 'Jake',
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,
},
]

1079
src/shared/data/workouts.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Feb 20, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5177 | 11:54 AM | ✅ | Exported getWeeklyActivity from stores index | ~158 |
| #5171 | 11:53 AM | 🔄 | Refactored activityStore to remove computed helpers from state | ~256 |
| #5170 | " | 🔵 | Activity store implementation with persistence | ~220 |
</claude-mem-context>

View File

@@ -0,0 +1,109 @@
/**
* TabataFit Activity Store
* Workout history, streak, stats — persisted via AsyncStorage
*/
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { WorkoutResult, DayActivity } from '../types'
interface ActivityState {
history: WorkoutResult[]
streak: { current: number; longest: number }
// Actions
addWorkoutResult: (result: WorkoutResult) => void
}
function getDateString(timestamp: number) {
return new Date(timestamp).toISOString().split('T')[0]
}
function calculateStreak(history: WorkoutResult[]): { current: number; longest: number } {
if (history.length === 0) return { current: 0, longest: 0 }
const uniqueDays = new Set(history.map(r => getDateString(r.completedAt)))
const sortedDays = Array.from(uniqueDays).sort().reverse()
const today = getDateString(Date.now())
const yesterday = getDateString(Date.now() - 86400000)
const hasRecentActivity = sortedDays[0] === today || sortedDays[0] === yesterday
if (!hasRecentActivity) return { current: 0, longest: calculateLongest(sortedDays) }
let current = 1
for (let i = 0; i < sortedDays.length - 1; i++) {
const diff = new Date(sortedDays[i]).getTime() - new Date(sortedDays[i + 1]).getTime()
if (diff <= 86400000) {
current++
} else {
break
}
}
return { current, longest: Math.max(current, calculateLongest(sortedDays)) }
}
function calculateLongest(sortedDays: string[]): number {
if (sortedDays.length === 0) return 0
let longest = 1
let streak = 1
for (let i = 0; i < sortedDays.length - 1; i++) {
const diff = new Date(sortedDays[i]).getTime() - new Date(sortedDays[i + 1]).getTime()
if (diff <= 86400000) {
streak++
longest = Math.max(longest, streak)
} else {
streak = 1
}
}
return longest
}
export const useActivityStore = create<ActivityState>()(
persist(
(set) => ({
history: [],
streak: { current: 0, longest: 0 },
addWorkoutResult: (result) =>
set((state) => {
const newHistory = [result, ...state.history]
const newStreak = calculateStreak(newHistory)
return {
history: newHistory,
streak: newStreak,
}
}),
}),
{
name: 'tabatafit-activity',
storage: createJSONStorage(() => AsyncStorage),
}
)
)
// Standalone helper functions (use outside selectors)
export function getWeeklyActivity(history: WorkoutResult[]): DayActivity[] {
const days: DayActivity[] = []
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const today = new Date()
const startOfWeek = new Date(today)
startOfWeek.setDate(today.getDate() - today.getDay())
for (let i = 0; i < 7; i++) {
const date = new Date(startOfWeek)
date.setDate(startOfWeek.getDate() + i)
const dateStr = getDateString(date.getTime())
const workoutsOnDay = history.filter(
r => getDateString(r.completedAt) === dateStr
)
days.push({
date: dayNames[i],
completed: workoutsOnDay.length > 0,
workoutCount: workoutsOnDay.length,
})
}
return days
}

View File

@@ -0,0 +1,7 @@
/**
* TabataFit Stores
*/
export { useUserStore } from './userStore'
export { useActivityStore, getWeeklyActivity } from './activityStore'
export { usePlayerStore } from './playerStore'

View File

@@ -0,0 +1,75 @@
/**
* TabataFit Player Store
* Current workout state — ephemeral (not persisted)
*/
import { create } from 'zustand'
import type { Workout } from '../types'
type TimerPhase = 'PREP' | 'WORK' | 'REST' | 'COMPLETE'
interface PlayerState {
workout: Workout | null
phase: TimerPhase
timeRemaining: number
currentRound: number
isPaused: boolean
isRunning: boolean
calories: number
startedAt: number | null
// Actions
loadWorkout: (workout: Workout) => void
setPhase: (phase: TimerPhase) => void
setTimeRemaining: (time: number) => void
setCurrentRound: (round: number) => void
setPaused: (paused: boolean) => void
setRunning: (running: boolean) => void
addCalories: (amount: number) => void
reset: () => void
}
export const usePlayerStore = create<PlayerState>((set) => ({
workout: null,
phase: 'PREP',
timeRemaining: 10,
currentRound: 1,
isPaused: false,
isRunning: false,
calories: 0,
startedAt: null,
loadWorkout: (workout) =>
set({
workout,
phase: 'PREP',
timeRemaining: workout.prepTime,
currentRound: 1,
isPaused: false,
isRunning: false,
calories: 0,
startedAt: null,
}),
setPhase: (phase) => set({ phase }),
setTimeRemaining: (time) => set({ timeRemaining: time }),
setCurrentRound: (round) => set({ currentRound: round }),
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({
workout: null,
phase: 'PREP',
timeRemaining: 10,
currentRound: 1,
isPaused: false,
isRunning: false,
calories: 0,
startedAt: null,
}),
}))

View File

@@ -0,0 +1,57 @@
/**
* TabataFit User Store
* Profile, settings, subscription — persisted via AsyncStorage
*/
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { UserProfile, UserSettings, SubscriptionPlan } from '../types'
interface UserState {
profile: UserProfile
settings: UserSettings
// Actions
updateProfile: (updates: Partial<UserProfile>) => void
updateSettings: (updates: Partial<UserSettings>) => void
setSubscription: (plan: SubscriptionPlan) => void
}
export const useUserStore = create<UserState>()(
persist(
(set) => ({
profile: {
name: 'Alex',
email: 'alex@example.com',
joinDate: 'January 2026',
subscription: 'premium-monthly',
},
settings: {
haptics: true,
soundEffects: true,
voiceCoaching: true,
reminders: false,
reminderTime: '09:00',
},
updateProfile: (updates) =>
set((state) => ({
profile: { ...state.profile, ...updates },
})),
updateSettings: (updates) =>
set((state) => ({
settings: { ...state.settings, ...updates },
})),
setSubscription: (plan) =>
set((state) => ({
profile: { ...state.profile, subscription: plan },
})),
}),
{
name: 'tabatafit-user',
storage: createJSONStorage(() => AsyncStorage),
}
)
)

View File

@@ -0,0 +1,32 @@
/**
* TabataFit Activity Types
*/
export interface WorkoutResult {
id: string
workoutId: string
completedAt: number
calories: number
durationMinutes: number
rounds: number
/** 0..1 */
completionRate: number
}
export interface DayActivity {
date: string
completed: boolean
workoutCount: number
}
export interface WeeklyStats {
days: DayActivity[]
totalWorkouts: number
totalMinutes: number
totalCalories: number
}
export interface Streak {
current: number
longest: number
}

View File

@@ -0,0 +1,8 @@
/**
* TabataFit Shared Types
*/
export * from './workout'
export * from './trainer'
export * from './user'
export * from './activity'

View File

@@ -0,0 +1,12 @@
/**
* TabataFit Trainer Types
*/
export interface Trainer {
id: string
name: string
specialty: string
color: string
avatarUrl?: string
workoutCount: number
}

29
src/shared/types/user.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* TabataFit User Types
*/
export type SubscriptionPlan = 'free' | 'premium-monthly' | 'premium-yearly'
export interface UserSettings {
haptics: boolean
soundEffects: boolean
voiceCoaching: boolean
reminders: boolean
reminderTime: string
}
export interface UserProfile {
name: string
email: string
joinDate: string
subscription: SubscriptionPlan
}
export interface Achievement {
id: string
title: string
description: string
icon: string
requirement: number
type: 'workouts' | 'streak' | 'minutes' | 'calories'
}

View File

@@ -0,0 +1,62 @@
/**
* TabataFit Workout Types
*/
export type WorkoutCategory = 'full-body' | 'core' | 'upper-body' | 'lower-body' | 'cardio'
export type WorkoutLevel = 'Beginner' | 'Intermediate' | 'Advanced'
export type WorkoutDuration = 4 | 8 | 12 | 20
export type MusicVibe = 'electronic' | 'hip-hop' | 'pop' | 'rock' | 'chill'
export interface Exercise {
name: string
/** Work duration in seconds */
duration: number
}
export interface Workout {
id: string
title: string
trainerId: string
category: WorkoutCategory
level: WorkoutLevel
/** Duration in minutes */
duration: WorkoutDuration
/** Estimated calories burned */
calories: number
exercises: Exercise[]
/** Total rounds (work+rest cycles) */
rounds: number
/** Prep time in seconds */
prepTime: number
/** Work interval in seconds */
workTime: number
/** Rest interval in seconds */
restTime: number
equipment: string[]
musicVibe: MusicVibe
thumbnailUrl?: string
videoUrl?: string
isFeatured?: boolean
}
export interface Collection {
id: string
title: string
description: string
icon: string
workoutIds: string[]
gradient?: [string, string]
}
export interface Program {
id: string
title: string
description: string
weeks: number
workoutsPerWeek: number
level: WorkoutLevel
workoutIds: string[]
}