Un plato aparecia en la lista de incumplimientos sin explicacion (captura
del usuario: "Ensalada campera"): findViolations listaba los slots con
isRuleOverridden, una marca puesta al asignar que quedaba obsoleta cuando
el conflicto ya se habia resuelto. Y al reves, editar una regla despues
de planificar no detectaba nada.
- findViolations recorre el plan y reporta solo lo que incumple AHORA,
siempre con sus razones (nunca una entrada vacia)
- MealSlot.isRuleIgnored: "Ignorar" pasa a ser persistente, porque si no
el aviso recalculado volveria enseguida; se resetea al cambiar o
quitar el plato del hueco
- El triangulo del calendario usa tambien el estado real
- Campo en el payload KV (opcional, retrocompatible) y en el esquema
CloudKit de Development
- 3 tests: marca obsoleta no se lista, regla endurecida despues se
detecta, y lo ignorado no reaparece
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
El panel de avisos solo se alcanzaba desde el menu "..." de la barra, asi
que los incumplimientos pasaban desapercibidos. Ahora, cuando la semana
rompe reglas, aparece un banner bajo el selector de semana con el numero
de comidas afectadas, un resumen de donde estan (Lun - Cena, Mie -
Comida) y un boton Ver que abre el panel con la explicacion y el arreglo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
- RuleViolation.reasons: el motor devuelve ahora QUE regla rompe cada
asignacion (repetido en la semana, maximo por semana superado, no
consecutivo, no mismo dia, solo comida/cena, solo entre semana o fin
de semana) con su etiqueta y limite
- Panel de avisos: bloque con cada regla rota en lenguaje claro + una
sugerencia de que hacer, y boton nuevo "Cambiar plato" que abre el
selector de ese hueco (antes solo se podia quitar o ignorar)
- Estadisticas: matriz "Etiquetas por tipo de comida" (p.ej. cuantas
cenas son de pescado) y "Platos por etiqueta" del catalogo
- Tests: 4 casos de las razones de incumplimiento; harness que renderiza
las secciones nuevas de stats a /tmp/stats_render.png
- Strings en 6 idiomas
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
En premium la barra de navegacion lleva cuatro botones a la izquierda y
mas a la derecha; el ToolbarItem(.principal) se comprimia y iOS
recortaba las flechas, dejando solo el titulo — por eso no aparecian en
el iPhone del usuario aunque en el simulador (modo free, dos botones) se
vieran bien. Ahora el selector es una barra propia bajo la navegacion:
flechas en circulo blanco con borde coral y el rango de semana con
chevron.down al centro. Verificado con captura forzando los botones
premium.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
Los chevrons finos coral sobre la barra ya coral eran invisibles en la
practica — un usuario premium no encontraba como planificar la semana
siguiente. Ahora son botones circulares blancos con chevron en negrita,
y el titulo lleva un chevron.down que delata que abre el selector de
semana. UI test de regresion: navegar a la semana siguiente debe
renderizar sus huecos sin paywall (la creacion del plan al navegar ya
funcionaba, verificado con captura).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
- El primer push se descartaba: updateApplicationContext se llamaba antes
de que WCSession terminara de activarse (activacion asincrona). Ahora
la sesion se activa al arrancar la app (ContentView), el payload
pendiente se envia al completarse la activacion, y ademas el reloj
PIDE los datos al abrirse (sendMessage despierta al iPhone en segundo
plano y responde con el snapshot del app group)
- Rediseno: Hoy con cabecera del dia en coral y tarjetas degradadas por
comida (estilo Weather); Semana con circulo de dia a la izquierda y
hoy relleno en coral (estilo Calendar); complicacion con iconos
tintados por comida y widgetAccentable
- Verificado con capturas en simulador de Watch sembrando el app group
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
- Target MealMoodWatch (watchOS 10, SwiftUI): vista de hoy y de la
semana en TabView vertical; recibe el snapshot del iPhone por
WatchConnectivity (updateApplicationContext) y lo guarda en su app
group para la complicacion
- Target MealMoodWatchWidget: complicacion accessoryRectangular/
Circular/Inline con las comidas de hoy (heuristica comida/cena por
hora); icono del watch generado desde el logo
- WatchWeekPayload compartido entre iOS app, widget iOS, watch app y
complicacion — llega ya localizado desde el iPhone
- Widget iOS nuevo "Semana" (systemMedium/Large) con los 7 dias;
el widget de hoy pasa a WidgetBundle
- Targets creados via gema xcodeproj (sin abrir Xcode); embed del watch
antes del script de Crashlytics para evitar ciclo de build
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
Guideline 3.1.2(c): Apple rechazo la 2.0.5 (77) por no tener enlaces
funcionales a la politica de privacidad y los terminos de uso dentro
del paywall. Anadidos en PremiumView y en el paywall del onboarding
(mealmood.app/privacy.html y terms.html), y ambos enlaces al final de
la descripcion del App Store en los 6 idiomas.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
- MealSlot.isSkipped: opcion "No planificar esta comida" en el picker de
hueco vacio — cuenta como resuelto (semana completa, notificaciones,
stats) y el autocompletado lo respeta; vista gris con guion, tap para
desmarcar; viaja en undo, snapshot KV (junto con isEatingOut, que
faltaba en el payload) y exports (— en imagen, omitido en texto)
- ShoppingItem.isDismissed: "Ya lo tengo" por ingrediente (swipe
izquierdo) y "Ya esta hecho" por plato entero (boton en la cabecera de
su seccion); van a la seccion "Ya en casa" con restauracion de un tap,
y quedan fuera del export de texto. No se borran porque reconcile los
recrearia
- Esquema CloudKit Development: CD_isSkipped, CD_isDismissed y el record
type CD_ShoppingItem entero, que no existia — la lista de la compra no
estaba sincronizando entre dispositivos (mismo bug que photoData)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
- Header elegante: logo fijado a la izquierda y titulo centrado de verdad
(ZStack), fecha a la derecha
- School transpuesto: dias como columnas y comidas como filas — antes con
1-2 comidas quedaba media pagina A4 apaisada en blanco
- Default: las celdas del grid se expanden para llenar la pagina (mismo
sintoma de hueco que el school)
- Estilo nuevo "Story" (9:16, 2160x3840) para stories de IG/WhatsApp y
"Minimal (imprimir)" en A4 con lineas finas sin fondos (ahorro tinta)
- Picker del preview pasa de segmentado a chips desplazables (5 estilos)
- WeekPlanTextExporter + boton "Copiar como texto (WhatsApp)" en el
preview: menu semanal con *negritas* y emojis listo para pegar en
WhatsApp/Telegram (evento analytics format=text)
- Harness de render en tests: genera PNG de los 5 estilos con datos de
ejemplo en /tmp/mealmood-export-previews para revisar el diseno
- Nombres de estilos en espanol corregidos (estaban en ingles)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
En school/vertical el logo centrado sobre el titulo quedaba mal; ahora
va a la izquierda con el rango de semana y el titulo alineados al lado,
y la fecha de export a la derecha.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
- Logo real de la app en el header de los 3 estilos (antes solo en el
default, y pequeno) y en el footer junto a un wordmark MealMood en
degradado coral->verde con tagline localizada en cursiva
- "Powered by MealMood" y "Scan me" estaban hardcodeados en ingles;
ahora share_footer_tagline y share_footer_scan en 6 idiomas
- Titulo del header default con el mismo degradado; QR con borde coral
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
- WeekExportPreviewSheet: preview renderizado del export con picker
segmentado de los 3 estilos (cachea cada render); compartir usa el
estilo elegido sin tocar el predeterminado (salvo primera vez)
- Sustituye al WeekExportStylePickerSheet de thumbnails y al doble
sheet picker+share de HomeView; strings nuevas en 6 idiomas
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
- MealSlot.secondaryDishId (opcional, aditivo CloudKit); se limpia al
sustituir/quitar/vaciar/marcar comer fuera y viaja en swaps, copia de
semana anterior, undo y snapshot KV de iCloud (retrocompatible)
- Picker de hueco lleno: toggle "anadir como segundo plato" + boton para
quitarlo; HomeViewModel.assignSecondaryDish/removeSecondaryDish
- Se muestra "Plato + Segundo" en calendario, widget y export; la lista
de la compra incluye los ingredientes del segundo; stats lo cuentan
- Evento analytics secondary_dish_added; strings en 6 idiomas
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
- Dish.fixedDayOfWeek/fixedMealType (opcionales, aditivos para CloudKit)
- Toggle + pickers de dia/comida en el formulario de plato
- AutocompleteEngine: pre-pasada que asigna los platos fijos a su hueco
antes del bucle MRV, de modo que cuentan para el resto de reglas
- Evento analytics dish_fixed_slot_set; strings en 6 idiomas
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
- attemptAddDish/attemptCreateDish: el limite gratuito se comprueba en el
tap de anadir plato (DishListView y los 3 puntos de Home) y muestra el
DishLimitModal -> paywall directamente; antes solo se comprobaba al
guardar el formulario y el usuario podia rellenarlo entero para nada
- DishLimitModal compartido (era private de DishFormView) y
PaywallPresentation promovido a PremiumView.swift; DishListView usa
sheet(item:) tambien para el banner (source dish_counter vs dish_limit)
- Boton en la toolbar de Mis Platos que alterna orden alfabetico
(persistido en dish_list_alphabetical_sort); borrado por swipe corregido
para usar la lista mostrada
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
- requestForExistingPremiumUser(): prompt nativo una sola vez para quien
ya era premium antes del ask post-compra (evento review_prompt_premium_user)
- Funnel de semanas completadas emite review_funnel_shown/positive/negative/dismissed
- Paywall de Home via sheet(item:) con PaywallPresentation: el source viaja
con la presentacion; antes dos @State separados hacian que a veces se
registrara paywall_viewed con source=unknown (post_onboarding nunca llegaba)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
- Tras una compra exitosa se pide la review nativa (una vez por usuario,
con 2s de delay para que se vea el estado premium activo)
- El boton de ajustes abre el formulario de resena del App Store via
?action=write-review en vez de SKStoreReviewController, que Apple
limita a 3 prompts/ano y el resto de veces no muestra nada
- Evento analytics review_prompt_post_purchase para medirlo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
La 2.0.3 nunca llego a la App Store, solo a TestFlight, asi que el numero queda
libre y evita saltarselo. El binario lleva la version dentro, y ASC solo adjunta
a una version las builds cuyo CFBundleShortVersionString coincide, asi que la
build 69 no se puede reetiquetar: hay que reconstruir.
Codigo identico al de la build 69, ya verificada en dispositivo.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ks8uUcMA9mjypVK7F2Pkt
El fix anterior arreglo los permisos —el crash se movio de start a beginSession,
y de TCC a AVAudioNodeTap::CheckEmitBuffer— pero el tap seguia petando.
Mi error: quitar el acceso a self del closure no elimina la inferencia de
aislamiento. La inferencia viene de DONDE se escribe el closure, no de lo que
captura. Escrito dentro de un metodo @MainActor, seguia siendo main-actor
isolated, y el tap se dispara desde el hilo de audio en tiempo real.
Ahora el handler se construye en makeTapHandler, que es nonisolated, y se pasa a
installTap. La estructura se auto-verifica: quitarle nonisolated a makeTapHandler
no compila, porque installTap es nonisolated y no puede llamar a un metodo
aislado. El bug pasa de crash en produccion a error de compilacion.
Tests: testTapHandlerRunsOffTheMainThread invoca el handler desde una cola de
fondo, que es exactamente la condicion que trapeaba. No hace falta microfono; el
intento anterior con AVAudioEngine se saltaba siempre porque el simulador no
tiene entrada de audio utilizable.
Barrido del mismo patron en el resto de servicios @MainActor: CalendarService y
NotificationService usan las variantes async/await, que estan anotadas y no
tienen closures. SpeechDictationService era el unico sitio.
Lanes feedback y crashlog para leer los reportes de TestFlight desde la API:
spaceship apunta a v1/betaFeedbacks, que Apple ya retiro; el endpoint vivo es
v1/apps/<id>/betaFeedbackCrashSubmissions y el log viene inline en logText.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ks8uUcMA9mjypVK7F2Pkt
Crashlytics: dispatch_assert_queue_fail en com.apple.root.default-qos, dentro de
closure #1 in SpeechDictationService.start, llamado desde __TCCAccessRequest.
El proyecto compila en Swift 6 (activo desde febrero, antes del dictado). Los
callbacks de APIs ObjC pre-concurrency escritos dentro de este tipo @MainActor se
infieren aislados al main actor, y Swift 6 inserta una comprobacion de aislamiento
al entrar. TCC invoca el handler en cola de fondo, la comprobacion falla y mata el
proceso. El Task { @MainActor } de dentro no ayuda: el crash es antes de llegar.
Cuatro sitios con el mismo patron, tres enmascarados detras del primero porque
nadie pasaba de los permisos:
- requestAuthorization y requestRecordPermission -> wrappers nonisolated con
continuation, el handler deja de estar aislado
- recognitionTask -> closure @Sendable explicito; solo cruzan valores Sendable
porque SFSpeechRecognitionResult no lo es
- installTap -> captura el request local en vez de ir por self, que el hilo de
audio en tiempo real no debe tocar estado del main actor
requestAuthorization llama al handler aunque el permiso ya este concedido, asi
que afectaba tambien a usuarios recurrentes: el dictado probablemente no ha
funcionado nunca desde la 2.0.
Los tests que acompanan NO reproducen el crash: el simulador entrega el callback
sincrono en el hilo principal y el codigo previo pasa igual. Comprobado
revirtiendo el fix. Queda pendiente validarlo en dispositivo real.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ks8uUcMA9mjypVK7F2Pkt
premium_purchase_failed con reason (cancelled/pending/unverified/error) en todas
las salidas no exitosas de StoreManager.purchase. Hasta ahora un usuario que se
echaba atras y uno al que le fallaba la compra eran indistinguibles: los dos
dejaban un premium_upgrade_tapped huerfano. El caso de Aleman (6 taps en 2 dias,
cero compras) no se puede diagnosticar sin esto.
Los UI tests dejan de mandar eventos a GA4 de produccion: launch argument
UITEST_DISABLE_ANALYTICS que hace no-op logEvent y apaga la recoleccion de
Firebase, para cortar tambien los automaticos (first_open, session_start,
screen_view). Ya habian contaminado los datos: los eventos de pasos 5/6 bajo la
version 2.0 eran ejecuciones de test, no usuarios.
Detalle que costo encontrar: setAnalyticsCollectionEnabled persiste entre
lanzamientos, asi que se escribe siempre (!isDisabled) y no solo al apagar. Si
no, un unico test dejaba analytics muerto para siempre en esa instalacion.
Verificado por pares de lanzamientos: con flag acaba en disabled, sin flag
vuelve a enabled.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ks8uUcMA9mjypVK7F2Pkt
La primera subida la rechazo App Store Connect con 90062: el IPA salio con
CFBundleShortVersionString 2.0. MARKETING_VERSION del pbxproj no manda; la
version real esta hardcodeada en MealMood/Resources/Info.plist y en el
Info.plist del widget.
Corregidos ambos a 2.0.1 y alineados en build 66. Documentado en CLAUDE.md que
un cambio de version toca tres sitios y que fastlane solo automatiza el build
number de la app.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ks8uUcMA9mjypVK7F2Pkt
El bug de los pasos 5 y 6 era invisible a los tests unitarios: el contrato de
datos estaba bien, lo que fallaba era que la vista se desmontaba antes de pintar
el aha moment y el paywall. Hace falta recorrer la UI de verdad.
Anade el target MealMoodUITests (solo simulador, sin firma) y un test que hace
el onboarding entero en instalacion limpia y comprueba que Week Ready y el
paywall aparecen antes de la Home.
Los CTA del onboarding llevan ahora accessibilityIdentifier para que el test no
dependa de los textos, que estan en 6 idiomas.
Validado en los dos sentidos: pasa con el fix, y revirtiendo temporalmente
markFinished: false falla en la asercion de Week Ready. Suite completa: 27 tests.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ks8uUcMA9mjypVK7F2Pkt
completeOnboarding escribia settings.onboardingCompleted al terminar el paso
de platos. Ese flag es el que ContentView observa para cambiar de OnboardingView
a HomeView, asi que el save destruia el flujo antes de que nextStep() pudiera
pintar el paso 5. WeekReadyStepView (el aha moment de la 1.2.0) y PaywallStepView
no se han visto nunca. GA4 lo confirma: 3 onboarding_completed en 28 dias y cero
eventos con step 5 o 6.
Se separa persistir los datos de marcar el onboarding como terminado:
completeOnboarding(markFinished:) commitea platos y plan sin tocar el flag, y el
nuevo finishOnboarding() lo escribe al salir del paywall. HomeView ya difiere su
prompt premium a la segunda sesion, asi que no hay paywall duplicado.
De paso, los nombres de paso en AnalyticsService estaban desalineados: el indice
5 decia "5_paywall" cuando el paso 5 es WeekReady, y el 6 no tenia nombre.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ks8uUcMA9mjypVK7F2Pkt