remove Expo project and all related files

Remove the entire Expo/React Native application: routes (app/), source
code (src/), assets, iOS native build, config plugins, StoreKit config,
npm dependencies, TypeScript/ESLint/Vitest configs, and Expo-specific
documentation. The repository now contains only: admin-web, supabase,
youtube-worker, tabatago-swift, docs, scripts, and CI/tooling configs.
This commit is contained in:
Millian Lamiaux
2026-04-21 21:55:00 +02:00
parent 8c90b73d90
commit 89cca25e22
285 changed files with 11212 additions and 44392 deletions

View File

@@ -0,0 +1,32 @@
import SwiftData
import Foundation
/// Snapshot of HealthKit data refreshed when user opens Activity tab.
@Model
final class HealthSnapshot: @unchecked Sendable {
var fetchedAt: Date = Date()
// Activity rings
var activeCaloricBurn: Double = 0 // kcal today
var exerciseMinutes: Double = 0 // minutes today
var standHours: Int = 0 // hours today
// Resting metrics
var restingHeartRate: Double? = nil // bpm
var bodyMassKg: Double? = nil // kg
// Weekly summary
var weeklyActiveCalories: Double = 0
var weeklyExerciseMinutes: Double = 0
var weeklyWorkoutCount: Int = 0
// VO2 Max (if available)
var vo2Max: Double? = nil
var isStale: Bool {
Date().timeIntervalSince(fetchedAt) > 900 // 15 min TTL
}
init() {}
}

View File

@@ -0,0 +1,31 @@
import Foundation
/// A single music track for workout playback.
struct MusicTrack: Identifiable, Equatable, Sendable {
let id: String
let title: String
let artist: String
let duration: Int // seconds
let url: URL
let vibe: MusicVibe
}
/// Workout music mood maps to genre buckets in the download_items table.
enum MusicVibe: String, CaseIterable, Sendable {
case electronic
case hipHop = "hip-hop"
case pop
case rock
case chill
/// Database genres that map to this vibe (matches Expo VIBE_TO_GENRES).
var genres: [String] {
switch self {
case .electronic: return ["edm", "house", "drum-and-bass", "dubstep"]
case .hipHop: return ["hip-hop", "r-and-b"]
case .pop: return ["pop", "latin"]
case .rock: return ["rock", "metal", "country"]
case .chill: return ["ambient"]
}
}
}

View File

@@ -0,0 +1,111 @@
import SwiftData
import Foundation
/// Seed data for SwiftUI previews and unit tests.
enum PreviewData {
static func seed(into context: ModelContext) {
// User profile
let profile = UserProfile()
profile.name = "Alex"
profile.fitnessLevel = .intermediate
profile.goal = .cardio
profile.weeklyFrequency = 4
profile.onboardingCompleted = true
profile.subscription = .premiumMonthly
context.insert(profile)
// Workout history
let sessions: [(String, String, String, Int)] = [
("upper-body-beginner", "Upper Body Blast", "upper", 1440),
("full-body-intermediate", "Full Body HIIT", "full", 2040),
("lower-body-beginner", "Lower Body Burn", "lower", 1200),
("upper-body-intermediate", "Arms & Core", "upper", 1680),
("full-body-beginner", "Total Body", "full", 960),
]
for (i, (id, title, zone, duration)) in sessions.enumerated() {
let daysAgo = Double(i * 2)
let session = WorkoutSession(
programId: id,
programTitle: title,
bodyZone: zone,
level: "Intermediate",
startedAt: Date().addingTimeInterval(-(daysAgo * 86400 + Double(duration))),
completedAt: Date().addingTimeInterval(-daysAgo * 86400),
durationSeconds: duration,
caloriesBurned: Double(duration / 10),
roundsCompleted: 8,
totalRounds: 8
)
context.insert(session)
}
// Health snapshot
let snapshot = HealthSnapshot()
snapshot.activeCaloricBurn = 320
snapshot.exerciseMinutes = 28
snapshot.standHours = 9
snapshot.restingHeartRate = 58
snapshot.bodyMassKg = 72
snapshot.weeklyActiveCalories = 1850
snapshot.weeklyExerciseMinutes = 148
snapshot.weeklyWorkoutCount = 4
context.insert(snapshot)
try? context.save()
}
/// A single workout program for player previews.
static var sampleProgram: WorkoutProgram {
WorkoutProgram(
id: "upper-body-beginner",
title: "Upper Body Blast",
titleEn: "Upper Body Blast",
description: "Sculpt your upper body with this intense Tabata workout.",
descriptionEn: "Sculpt your upper body with this intense Tabata workout.",
bodyZone: "upper",
level: "Beginner",
musicVibe: "electronic",
accentColor: "#FF6B35",
isFree: true,
estimatedCalories: 180,
estimatedDuration: 24,
totalRounds: 32,
warmup: WarmupSection(
movements: [
TimedMovement(name: "Arm Circles", nameEn: "Arm Circles", duration: 30, videoUrl: nil),
TimedMovement(name: "High Knees", nameEn: "High Knees", duration: 30, videoUrl: nil),
],
totalDuration: 150
),
cooldown: CooldownSection(
movements: [
TimedMovement(name: "Shoulder Stretch", nameEn: "Shoulder Stretch", duration: 30, videoUrl: nil),
],
totalDuration: 90
),
blocks: [
TabataBlock(
id: "block-1",
position: 1,
exercise1: TabataExercise(name: "Push-ups", nameEn: "Push-ups", tip: "Keep core tight"),
exercise2: TabataExercise(name: "Tricep Dips", nameEn: "Tricep Dips"),
rounds: 8,
workTime: 20,
restTime: 10
),
TabataBlock(
id: "block-2",
position: 2,
exercise1: TabataExercise(name: "Pike Push-ups", nameEn: "Pike Push-ups"),
exercise2: TabataExercise(name: "Plank Shoulder Taps", nameEn: "Plank Shoulder Taps"),
rounds: 8,
workTime: 20,
restTime: 10
),
],
thumbnailUrl: nil
)
}
}

View File

@@ -0,0 +1,41 @@
import SwiftData
import Foundation
/// Central schema definition + ModelContainer factory.
enum TabataGoSchema {
static var schema: Schema {
Schema([
UserProfile.self,
WorkoutSession.self,
CachedProgramData.self,
HealthSnapshot.self,
])
}
static var container: ModelContainer {
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
allowsSave: true
)
do {
return try ModelContainer(for: schema, configurations: config)
} catch {
fatalError("Failed to create SwiftData container: \(error)")
}
}
/// In-memory container for SwiftUI Previews and unit tests.
@MainActor
static var previewContainer: ModelContainer {
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
guard let container = try? ModelContainer(for: schema, configurations: config) else {
// Last-resort: if even a bare in-memory container fails, the schema itself is
// broken and we want a clear crash with a useful message rather than a silent hang.
fatalError("[TabataGoSchema] Cannot create even a bare in-memory ModelContainer — check your @Model definitions for conflicts.")
}
PreviewData.seed(into: container.mainContext)
return container
}
}

View File

@@ -0,0 +1,123 @@
import SwiftData
import Foundation
// Enums
enum FitnessLevel: String, Codable, CaseIterable {
case beginner, intermediate, advanced
var label: String {
switch self {
case .beginner: "Beginner"
case .intermediate: "Intermediate"
case .advanced: "Advanced"
}
}
}
enum FitnessGoal: String, Codable, CaseIterable {
case weightLoss = "weight-loss"
case cardio, strength, wellness
var label: String {
switch self {
case .weightLoss: "Weight Loss"
case .cardio: "Cardio"
case .strength: "Strength"
case .wellness: "Wellness"
}
}
}
enum SubscriptionPlan: String, Codable {
case free
case premiumMonthly = "premium-monthly"
case premiumYearly = "premium-yearly"
var isPremium: Bool { self != .free }
}
enum SyncStatus: String, Codable {
case neverSynced = "never-synced"
case promptPending = "prompt-pending"
case synced
case failed
}
// Model
/// Singleton user profile created once at onboarding, updated in place.
@Model
final class UserProfile {
// Identity
var name: String = ""
var email: String = ""
var joinDate: Date = Date()
// Fitness preferences
var fitnessLevelRaw: String = FitnessLevel.beginner.rawValue
var goalRaw: String = FitnessGoal.cardio.rawValue
var weeklyFrequency: Int = 3
var barriers: [String] = []
// Subscription
var subscriptionRaw: String = SubscriptionPlan.free.rawValue
// Onboarding
var onboardingCompleted: Bool = false
// Cloud sync
var syncStatusRaw: String = SyncStatus.neverSynced.rawValue
var supabaseUserId: String? = nil
// App settings
var hapticsEnabled: Bool = true
var soundEffectsEnabled: Bool = true
var voiceCoachingEnabled: Bool = true
var musicEnabled: Bool = true
var musicVolume: Double = 0.5
var remindersEnabled: Bool = false
var reminderTimeHour: Int = 9
var reminderTimeMinute: Int = 0
var hasPromptedReview: Bool = false
// Saved workouts
var savedWorkoutIds: [String] = []
// Computed accessors
var fitnessLevel: FitnessLevel {
get { FitnessLevel(rawValue: fitnessLevelRaw) ?? .beginner }
set { fitnessLevelRaw = newValue.rawValue }
}
var goal: FitnessGoal {
get { FitnessGoal(rawValue: goalRaw) ?? .cardio }
set { goalRaw = newValue.rawValue }
}
var subscription: SubscriptionPlan {
get { SubscriptionPlan(rawValue: subscriptionRaw) ?? .free }
set { subscriptionRaw = newValue.rawValue }
}
var syncStatus: SyncStatus {
get { SyncStatus(rawValue: syncStatusRaw) ?? .neverSynced }
set { syncStatusRaw = newValue.rawValue }
}
var reminderTime: DateComponents {
get {
var c = DateComponents()
c.hour = reminderTimeHour
c.minute = reminderTimeMinute
return c
}
set {
reminderTimeHour = newValue.hour ?? 9
reminderTimeMinute = newValue.minute ?? 0
}
}
init() {}
}

View File

@@ -0,0 +1,89 @@
import SwiftData
import Foundation
// Value types (not persisted, used in memory)
struct TabataExercise: Codable, Hashable, Sendable {
var name: String
var nameEn: String
var tip: String?
var tipEn: String?
var modification: String?
var modificationEn: String?
var progression: String?
var progressionEn: String?
var videoUrl: String?
}
struct TabataBlock: Codable, Hashable, Sendable {
var id: String
var position: Int
var exercise1: TabataExercise
var exercise2: TabataExercise
var rounds: Int
var workTime: Int // seconds
var restTime: Int // seconds
}
struct TimedMovement: Codable, Hashable, Sendable {
var name: String
var nameEn: String
var duration: Int // seconds
var videoUrl: String?
}
struct WarmupSection: Codable, Hashable, Sendable {
var movements: [TimedMovement]
var totalDuration: Int
}
struct CooldownSection: Codable, Hashable, Sendable {
var movements: [TimedMovement]
var totalDuration: Int
}
/// In-memory representation of a full workout program (decoded from Supabase cache).
struct WorkoutProgram: Codable, Hashable, Identifiable, Sendable {
var id: String
var title: String
var titleEn: String
var description: String
var descriptionEn: String
var bodyZone: String
var level: String
var musicVibe: String
var accentColor: String
var isFree: Bool
var estimatedCalories: Int
var estimatedDuration: Int // minutes
var totalRounds: Int
var warmup: WarmupSection
var cooldown: CooldownSection
var blocks: [TabataBlock]
var thumbnailUrl: String?
}
// SwiftData cache model
/// Supabase workout programs cached locally. TTL = 1 hour.
@Model
final class CachedProgramData {
var cacheKey: String = ""
var cachedAt: Date = Date()
var jsonData: Data = Data()
var isExpired: Bool {
Date().timeIntervalSince(cachedAt) > 3600 // 1 hour TTL
}
init(cacheKey: String, programs: [WorkoutProgram]) throws {
self.cacheKey = cacheKey
self.cachedAt = Date()
self.jsonData = try JSONEncoder().encode(programs)
}
func decode() throws -> [WorkoutProgram] {
try JSONDecoder().decode([WorkoutProgram].self, from: jsonData)
}
}

View File

@@ -0,0 +1,60 @@
import SwiftData
import Foundation
/// One completed Tabata workout session written to SwiftData + HealthKit.
@Model
final class WorkoutSession: @unchecked Sendable {
var id: UUID = UUID()
var programId: String = ""
var programTitle: String = ""
var bodyZone: String = ""
var level: String = ""
// Timing
var startedAt: Date = Date()
var completedAt: Date = Date()
var durationSeconds: Int = 0
// Effort
var caloriesBurned: Double = 0
var roundsCompleted: Int = 0
var totalRounds: Int = 0
/// Completion rate 0..1
var completionRate: Double {
guard totalRounds > 0 else { return 0 }
return Double(roundsCompleted) / Double(totalRounds)
}
// HealthKit link
var healthKitWorkoutId: UUID? = nil
// Heart rate (populated from HKWorkoutSession after completion)
var averageHeartRate: Double? = nil
var peakHeartRate: Double? = nil
init(
programId: String,
programTitle: String,
bodyZone: String,
level: String,
startedAt: Date,
completedAt: Date,
durationSeconds: Int,
caloriesBurned: Double,
roundsCompleted: Int,
totalRounds: Int
) {
self.programId = programId
self.programTitle = programTitle
self.bodyZone = bodyZone
self.level = level
self.startedAt = startedAt
self.completedAt = completedAt
self.durationSeconds = durationSeconds
self.caloriesBurned = caloriesBurned
self.roundsCompleted = roundsCompleted
self.totalRounds = totalRounds
}
}