Skip to content

Latest commit

 

History

History
547 lines (431 loc) · 18.1 KB

File metadata and controls

547 lines (431 loc) · 18.1 KB

Modelos de Datos

Este documento describe todos los @Model persistidos con SwiftData, los structs auxiliares (Codable, @Generable) y las relaciones entre ellos. Todos los modelos viven en DucodeApp/Models/ salvo HealthSummary y CoachContext que residen junto a sus servicios.

Índice


Persistidos en SwiftData

El ModelContainer se crea una sola vez en DucodeApp.swift con todos los modelos:

modelContainer = try ModelContainer(
    for: Alimento.self,
        Objetivo.self,
        DiaRegistro.self,
        WhoopDailySnapshot.self,
        UserPreferences.self,
        ChatMessage.self
)

Resumen:

Modelo Función
Alimento Items del inventario con macros y flag activo/inactivo
Objetivo Meta del usuario + antropometría → TMB/TDEE
DiaRegistro Registro diario (Whoop snapshot + comidas + totales)
WhoopDailySnapshot Snapshot consolidado por día sincronizado desde Whoop
UserPreferences Cocinas, restricciones, alergias, dislikes, notas extra
ChatMessage Historial del chat con el Coach (rol + contenido + fecha)

Alimento

@Model
final class Alimento {
    var id: UUID
    var nombre: String
    var proteina: Double          // gramos
    var carbohidratos: Double     // gramos
    var grasa: Double             // gramos
    var calorias: Double
    var cantidadDisponible: Double
    var unidad: String            // "g", "ml", "unidad", "porción"
    var fechaAgregado: Date
    var activo: Bool              // visible en sugerencias / planes
}

Convención: macros expresadas por 100g/100ml/unidad. El AI prioriza ingredientes activos al generar planes.

Alimento.ejemplos contiene una lista predefinida (nombre, proteina, carbos, grasa, kcal) usada en el onboarding y en "Sugerencias rápidas" de la pestaña Comidas.

Objetivo

Meta del usuario y antropometría. El TDEE se calcula con la fórmula de Mifflin-St Jeor (variante para hombres).

@Model
final class Objetivo {
    var id: UUID
    var tipo: TipoObjetivo        // .perdida / .mantenimiento / .ganancia
    var pesoActual: Double        // kg
    var pesoMeta: Double          // kg
    var altura: Double            // cm
    var edad: Int
    var nivelActividad: Double    // 1.2 (sedentario) ... 1.9 (atleta)
    var proteinaPorKg: Double     // por defecto 2.0
    var fechaCreacion: Date
    var activo: Bool

    var tmb: Double {             // Mifflin-St Jeor (hombres)
        (10 * pesoActual) + (6.25 * altura) - (5 * Double(edad)) + 5
    }
    var tdee: Double { tmb * nivelActividad }
    var caloriasObjetivo: Double { tdee + tipo.ajusteCaloricoBase }
    var proteinaObjetivo: Double { pesoActual * proteinaPorKg }
}

TipoObjetivo:

Caso Ajuste calórico base Descripción
.perdida -500 kcal Déficit calórico para perder grasa
.mantenimiento 0 kcal Mantener peso y composición actual
.ganancia +300 kcal Superávit calórico para ganar músculo

Solo un objetivo está activo a la vez. El onboarding y las pantallas de edición desactivan los anteriores antes de insertar el nuevo.

DiaRegistro + ComidaRegistro

Registro nutricional de un día. Las comidas planificadas y reales se serializan a JSON dentro de strings para simplificar la persistencia.

@Model
final class DiaRegistro {
    var id: UUID
    var fecha: Date  // siempre startOfDay

    // Datos snapshot de Whoop
    var recoveryScore: Double?
    var strainScore: Double?
    var hrv: Double?
    var restingHR: Double?
    var sleepScore: Double?
    var sleepDuration: Double?
    var kilojoules: Double?
    var workoutType: String?

    // JSON-encoded
    var comidasPlanificadas: String?  // [ComidaRegistro]
    var comidasReales: String?        // [ComidaRegistro]

    // Totales del día
    var caloriasConsumidas: Double
    var proteinaConsumida: Double
    var carbohidratosConsumidos: Double
    var grasaConsumida: Double

    // Meta del día (ajustada por Whoop)
    var caloriasMeta: Double
    var proteinaMeta: Double

    var cumplimientoCalorias: Double { ... }    // % vs meta, capado a 150
    var cumplimientoProteina: Double { ... }
}

ajustarMetasConWhoop(objetivoBase:) modifica las metas del día:

  • Si hay strain, suma/resta (strain - 10) * 30 kcal.
  • Si recovery < 50, multiplica calorías por 0.95.
  • Si hay kilojoules, agrega kj/4.184 * 0.5 kcal (50% del gasto medido).

ComidaRegistro es un Codable struct (no @Model):

struct ComidaRegistro: Codable, Identifiable {
    var id: UUID
    var tipo: TipoComida          // Desayuno, Almuerzo, Cena, Snack, PreEntreno, PostEntreno
    var alimentoNombre: String
    var cantidad: Double
    var unidad: String
    var calorias: Double
    var proteina: Double
    var carbohidratos: Double
    var grasa: Double
    var hora: Date
}

WhoopDailySnapshot

Snapshot diario consolidado de los datos de Whoop. Cabe todo en un único registro por día para facilitar queries y agregaciones semanales.

@Model
final class WhoopDailySnapshot {
    var id: UUID
    var date: Date            // startOfDay
    var syncedAt: Date

    // Recovery
    var recoveryScore: Double?
    var hrvRmssd: Double?
    var restingHeartRate: Double?
    var spo2Percentage: Double?
    var skinTempCelsius: Double?

    // Cycle / Strain
    var strain: Double?
    var kilojoules: Double?
    var averageHeartRate: Int?
    var maxHeartRate: Int?

    // Sleep
    var sleepDurationMs: Int?
    var sleepPerformance: Double?
    var sleepEfficiency: Double?
    var sleepConsistency: Double?
    var remSleepMs: Int?
    var deepSleepMs: Int?
    var lightSleepMs: Int?
    var awakeMs: Int?
    var sleepDebtMilli: Int?  // need_from_sleep_debt_milli

    // Workouts del día
    var workoutsJson: String?

    var caloriesBurned: Double { (kilojoules ?? 0) / 4.184 }
    var sleepHours: Double { Double(sleepDurationMs ?? 0) / 3_600_000 }
    var workouts: [WorkoutSummary] { /* decodifica workoutsJson */ }
}

El sync compara fechas con calendario UTC (Whoop sirve en UTC) para evitar duplicados causados por timezones locales.

UserPreferences

Preferencias alimenticias. Se mantiene un único registro: el código siempre hace preferences.first y, si no existe, inserta uno nuevo.

@Model
final class UserPreferences {
    var id: UUID
    var cuisinesRaw: [String]            // ["Colombiana", "Mediterránea"]
    var dietaryRaw: [String]             // ["Vegetariano", "Sin gluten"]
    var allergies: String                // "maní, mariscos"
    var dislikes: String                 // "cilantro, brócoli"
    var extraNotes: String?              // opcional → migración lightweight
    var fechaCreacion: Date

    var cuisines: [Cuisine]              // computed (compactMap raw)
    var dietary: [DietaryRestriction]    // computed
    var extraNotesText: String           // get/set seguro
}

extraNotes es opcional para soportar migración lightweight de SwiftData en filas existentes cuando se agregó el campo.

enum Cuisine

Caso Emoji promptHint (extracto)
colombiana 🇨🇴 Bandeja Paisa, Sancocho, Ajiaco, Arepa con Huevo, Patacones
mexicana 🌮 tacos, burritos, pozole, chilaquiles, enchiladas
mediterranea 🫒 ensaladas griegas, pescados, hummus, falafel, tabule, paella
asiatica 🥢 arroz frito, stir-fry, ramen, sushi bowls, pho, curry
italiana 🍝 pastas, risotto, minestrone, parmigiana, caprese
americana 🍔 bowls, hamburguesas magras, ensaladas, BBQ chicken
peruana 🇵🇪 ceviche, lomo saltado, ají de gallina, causa, chaufa
sinPreferencia comida casera saludable internacional, variada

promptHint se inyecta literalmente al system prompt del nutricionista AI.

enum DietaryRestriction

Caso Icon promptRule
vegetariano leaf.fill sin carne ni pescado (puede incluir huevos y lácteos)
vegano carrot.fill 100% basado en plantas, sin productos animales
pescetariano fish.fill sin carnes rojas ni de ave (sí pescado y mariscos)
sinGluten circle.slash sin trigo, cebada, centeno (sin pan, pasta común, etc.)
sinLacteos drop.fill sin leche, queso, yogurt ni derivados
bajoCarbo chart.line.downtrend.xyaxis máximo 100g de carbohidratos al día

ChatMessage

Persistencia del chat con el Coach. Mensajes en orden cronológico; se borran todos al hacer "Resetear conversación".

enum ChatRole: String, Codable {
    case user
    case assistant
    case system
}

@Model
final class ChatMessage {
    var id: UUID
    var roleRaw: String
    var content: String
    var timestamp: Date

    var role: ChatRole {
        ChatRole(rawValue: roleRaw) ?? .user
    }
}

roleRaw se persiste como String por compatibilidad con SwiftData; el getter role devuelve el enum.


No persistidos (in-memory)

WhoopWeeklyAnalytics + WorkoutSummary

Calculados a partir de los snapshots de la última semana. Se construyen en WhoopSyncService.loadCachedAnalytics() y se publican como weeklyAnalytics: WhoopWeeklyAnalytics?.

struct WhoopWeeklyAnalytics {
    let snapshots: [WhoopDailySnapshot]

    var daysWithData: Int
    var avgRecovery: Double?
    var avgStrain: Double?
    var avgSleepHours: Double?
    var avgHRV: Double?
    var avgCaloriesBurned: Double?
    var totalCaloriesBurned: Double
    var totalWorkouts: Int
    var workoutDays: Int
    var recoveryTrend: Trend       // .up / .down / .stable
    var strainTrend: Trend
    var sleepTrend: Trend
    var sleepDebtHours: Double     // desde need_from_sleep_debt_milli, fallback al cálculo manual
    var workoutsByType: [String: Int]

    func generatePromptContext() -> String  // bloque de texto para el LLM
}

Trend.calculateTrend(_:) divide los valores en dos mitades y compara promedios con threshold del 10%.

struct WorkoutSummary: Codable, Identifiable {
    var id: String
    var sportId: Int?
    var sportName: String?
    var start: Date
    var end: Date
    var strain: Double?
    var kilojoules: Double?
    var averageHeartRate: Int?
    var maxHeartRate: Int?
    var distanceMeters: Double?

    var durationMinutes: Double { end.timeIntervalSince(start) / 60 }
    var caloriesBurned: Double { (kilojoules ?? 0) / 4.184 }
}

HealthSummary

Vive en Services/HealthKitService.swift. Es el resultado consolidado de las queries a HealthKit.

struct HealthSummary {
    // Body composition
    var weightKg: Double?
    var heightCm: Double?
    var bodyFatPercent: Double?
    var leanMassKg: Double?
    var bmi: Double?

    // Today's activity
    var stepsToday: Int?
    var activeKcalToday: Double?
    var basalKcalToday: Double?
    var distanceKmToday: Double?
    var exerciseMinutesToday: Int?
    var standHoursToday: Int?
    var flightsClimbedToday: Int?

    // Vitals (latest)
    var restingHeartRate: Double?
    var hrvMs: Double?
    var spo2Percent: Double?
    var respiratoryRate: Double?
    var vo2Max: Double?

    // Sleep last night
    var sleepHoursLastNight: Double?
    var sleepEfficiency: Double?

    // Nutrition logged elsewhere today
    var loggedKcalToday: Double?
    var loggedProteinToday: Double?
    var loggedCarbsToday: Double?
    var loggedFatToday: Double?
    var waterMlToday: Double?

    // Recent workouts (last 7 days)
    var workoutsLast7DaysCount: Int = 0
    var workoutsLast7DaysKcal: Double = 0
    var topWorkoutTypes: [String] = []

    // Mindfulness
    var mindfulMinutesToday: Int?

    var hasAnyData: Bool { ... }
    func generatePromptContext() -> String
}

generatePromptContext() produce un bloque tipo:

DATOS DE APPLE HEALTH:
- Peso actual: 76.4kg, grasa 18.2%, masa magra 62.5kg
- Hoy: 8421 pasos, 412 kcal activas, 6.1km, 32 min ejercicio
- Vitales: RHR 54bpm, HRV 68ms, VO2max 48.1
- Sueño anoche: 7.4h (88% eficiencia)
- Workouts 7 días: 5 sesiones, 2840 kcal (Strength, Running, Cycling)
- Ya consumido hoy: 1240 kcal, P92g, C140g, G34g
- Agua hoy: 1800ml

Si hasAnyData == false, los servicios omiten este bloque del prompt.

CoachContext

Bundle de datos que CoachChatService recibe en cada send(_:context:) y ensureSession(context:). Vive en Services/CoachChatService.swift.

struct CoachContext {
    var userName: String?
    var objetivo: Objetivo?
    var preferences: UserPreferences?
    var recoveryScore: Double?
    var strainScore: Double?
    var sleepHours: Double?
    var hrv: Double?
    var weeklyAnalytics: WhoopWeeklyAnalytics?
    var healthSummary: HealthSummary?
    var currentPlan: PlanDiario?
    var totalAlimentos: Int = 0
    var ultimasComidas: [String] = []
}

El servicio compara el resultado de buildInstructions(context:) con el último system prompt usado. Si cambia, recrea la LanguageModelSession. Esto permite refrescar el contexto sin perder el chat cuando los datos no cambian, y resetearlo cuando sí.


@Generable (Foundation Models)

@Generable es un macro de Foundation Models que garantiza que el modelo devuelva una instancia válida de la struct. @Guide adjunta una descripción que el modelo respeta.

Definidas como private en NutritionAIService.swift (no se exponen al resto del código; se mapean a structs de display).

@Generable
private struct PlanGenerado {
    @Guide(description: "Análisis personalizado en 1-2 frases del estado del usuario y por qué este plan")
    let resumen: String

    @Guide(description: "Calorías totales del día ajustadas según objetivo y estado físico")
    let caloriasTotales: Int

    @Guide(description: "Proteína total del día en gramos")
    let proteinaTotal: Int

    @Guide(description: "Comidas del día en orden cronológico: desayuno, almuerzo, snack, cena")
    let comidas: [ComidaGenerada]

    @Guide(description: "2 a 3 consejos cortos de hidratación, recuperación o nutrición")
    let consejos: [String]
}

@Generable
private struct ComidaGenerada {
    @Guide(description: "Tipo de comida, uno de: Desayuno, Almuerzo, Snack, Cena")
    let tipo: String

    @Guide(description: "Nombre del plato según el estilo de cocina del usuario indicado en las instrucciones")
    let nombrePlato: String

    @Guide(description: "Descripción breve del plato en 1 frase")
    let descripcion: String

    @Guide(description: "Ingredientes con cantidades concretas")
    let ingredientes: [IngredienteGenerado]

    @Guide(description: "Calorías totales de la comida")
    let calorias: Int

    @Guide(description: "Proteína de la comida en gramos")
    let proteina: Int

    @Guide(description: "Carbohidratos de la comida en gramos")
    let carbohidratos: Int

    @Guide(description: "Grasa de la comida en gramos")
    let grasa: Int
}

@Generable
private struct IngredienteGenerado {
    let nombre: String

    @Guide(description: "Cantidad con unidad concreta: '100g', '2 unidades', '1 taza', '50ml'")
    let cantidad: String

    @Guide(description: "Calorías aproximadas del ingrediente")
    let calorias: Int
}

generarPlanDiario(...) invoca session.respond(to: prompt, generating: PlanGenerado.self). regenerarComida(at:) invoca session.respond(to: prompt, generating: ComidaGenerada.self) para reemplazar una sola entrada del array.


Modelos de display

Estructuras públicas consumidas por las views. Son lo que currentPlan: PlanDiario? expone desde NutritionAIService.

struct Ingrediente: Identifiable {
    let id = UUID()
    let nombre: String
    let cantidad: String
    let calorias: Int
}

struct RecomendacionComida: Identifiable {
    let id = UUID()
    let tipo: String              // "Desayuno" / "Almuerzo" / "Snack" / "Cena"
    let nombrePlato: String
    let descripcion: String
    let ingredientes: [Ingrediente]
    let calorias: Int
    let proteina: Int
    let carbohidratos: Int
    let grasa: Int

    var alimentos: [String] { ... }
}

struct PlanDiario {
    let fecha: Date
    let resumenEstado: String
    let caloriasTotales: Int
    let proteinaTotales: Int
    let comidas: [RecomendacionComida]
    let consejos: [String]
}

El id = UUID() en Ingrediente y RecomendacionComida permite que SwiftUI siga el ciclo de vida correctamente cuando se regenera una sola comida (replaceMeal(at:with:) reconstruye el array completo).


Documentos relacionados