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.
- Persistidos en SwiftData
- No persistidos (in-memory)
- @Generable (Foundation Models)
- Modelos de display
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) |
@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.
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.
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) * 30kcal. - Si
recovery < 50, multiplica calorías por 0.95. - Si hay
kilojoules, agregakj/4.184 * 0.5kcal (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
}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.
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.
| 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.
| 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 |
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.
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 }
}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.
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 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.
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).
- ARCHITECTURE.md — capas y servicios
- INTEGRATIONS.md — Whoop, Foundation Models, HealthKit
- COACH.md — chat conversacional