From 4579b9bb69920467d871fdfe856c7a59b3878d19 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Sat, 12 Sep 2026 16:32:34 +0200 Subject: [PATCH] watch: enviar la semana aunque todavia no este planificada MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Faltaba la otra mitad del "no se actualiza": makePayload devolvia nil cuando la semana en curso no tenia plan creado, asi que el iPhone no mandaba nada — ni respondia cuando el reloj pedia datos — y el reloj se quedaba con el ultimo snapshot, el del miercoles. Ahora la semana viaja igualmente, vacia, y el reloj dice que no hay nada planificado en vez de enseñar lo de hace dias. Ademas el reloj muestra cuando se actualizo por ultima vez. Sin eso, un snapshot viejo y uno recien llegado se ven igual, y no hay forma de saber si el problema es el envio o lo que se pinta. Refs #34, #35 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246 --- .../Resources/de.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/es.lproj/Localizable.strings | 1 + .../Resources/fr.lproj/Localizable.strings | 1 + .../Resources/it.lproj/Localizable.strings | 1 + .../Resources/pt-BR.lproj/Localizable.strings | 1 + MealMood/Services/WatchSyncService.swift | 21 ++++++++----- MealMood/Services/WatchWeekPayload.swift | 3 ++ MealMoodTests/WatchWeekPayloadTests.swift | 30 +++++++++++++++++++ MealMoodWatch/TodayView.swift | 23 ++++++++++++++ 10 files changed, 76 insertions(+), 7 deletions(-) diff --git a/MealMood/Resources/de.lproj/Localizable.strings b/MealMood/Resources/de.lproj/Localizable.strings index c92f13c..6fdcddc 100644 --- a/MealMood/Resources/de.lproj/Localizable.strings +++ b/MealMood/Resources/de.lproj/Localizable.strings @@ -483,3 +483,4 @@ "watch_empty_meal" = "Nicht geplant"; "watch_empty_day" = "Für heute ist nichts geplant"; "watch_stale" = "Öffne MealMood auf dem iPhone, um diese Woche zu sehen"; +"watch_updated" = "Aktualisiert"; diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index 6281188..1e8b2c8 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -483,3 +483,4 @@ "watch_empty_meal" = "Not planned"; "watch_empty_day" = "Nothing planned for today"; "watch_stale" = "Open MealMood on your iPhone to see this week"; +"watch_updated" = "Updated"; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index 67001f6..93c28dd 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -483,3 +483,4 @@ "watch_empty_meal" = "Sin planificar"; "watch_empty_day" = "Hoy no hay nada planificado"; "watch_stale" = "Abre MealMood en el iPhone para ver esta semana"; +"watch_updated" = "Actualizado"; diff --git a/MealMood/Resources/fr.lproj/Localizable.strings b/MealMood/Resources/fr.lproj/Localizable.strings index f1f05ad..ee4b6d1 100644 --- a/MealMood/Resources/fr.lproj/Localizable.strings +++ b/MealMood/Resources/fr.lproj/Localizable.strings @@ -483,3 +483,4 @@ "watch_empty_meal" = "Non planifié"; "watch_empty_day" = "Rien de prévu aujourd'hui"; "watch_stale" = "Ouvrez MealMood sur l'iPhone pour voir cette semaine"; +"watch_updated" = "Mis à jour"; diff --git a/MealMood/Resources/it.lproj/Localizable.strings b/MealMood/Resources/it.lproj/Localizable.strings index a310108..8b3f1aa 100644 --- a/MealMood/Resources/it.lproj/Localizable.strings +++ b/MealMood/Resources/it.lproj/Localizable.strings @@ -483,3 +483,4 @@ "watch_empty_meal" = "Non pianificato"; "watch_empty_day" = "Oggi non c'è niente di pianificato"; "watch_stale" = "Apri MealMood sull'iPhone per vedere questa settimana"; +"watch_updated" = "Aggiornato"; diff --git a/MealMood/Resources/pt-BR.lproj/Localizable.strings b/MealMood/Resources/pt-BR.lproj/Localizable.strings index b7c067d..16f5d99 100644 --- a/MealMood/Resources/pt-BR.lproj/Localizable.strings +++ b/MealMood/Resources/pt-BR.lproj/Localizable.strings @@ -483,3 +483,4 @@ "watch_empty_meal" = "Sem planejamento"; "watch_empty_day" = "Nada planejado para hoje"; "watch_stale" = "Abra o MealMood no iPhone para ver esta semana"; +"watch_updated" = "Atualizado"; diff --git a/MealMood/Services/WatchSyncService.swift b/MealMood/Services/WatchSyncService.swift index 319acc1..3feb1f1 100644 --- a/MealMood/Services/WatchSyncService.swift +++ b/MealMood/Services/WatchSyncService.swift @@ -46,7 +46,8 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable { let descriptor = FetchDescriptor( predicate: #Predicate { plan in plan.weekStartDate == weekStart } ) - guard let plan = try? context.fetch(descriptor).first else { return nil } + // No plan for this week yet? Answer with the empty week anyway. + let plan = try? context.fetch(descriptor).first let dishes = (try? context.fetch(FetchDescriptor())) ?? [] return Self.makePayload(plan: plan, dishes: dishes, settings: settings)?.encoded() @@ -67,8 +68,13 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable { try? WCSession.default.updateApplicationContext([WatchWeekPayload.storageKey: data]) } + /// Builds the snapshot for a week. A missing plan is NOT a reason to stay + /// quiet: without this the watch keeps whatever it had — which is how it + /// went on showing Wednesday's dinner on Saturday. An empty week travels + /// too, and the watch says there's nothing planned. static func makePayload(plan: WeekPlan?, dishes: [Dish], settings: AppSettings) -> WatchWeekPayload? { - guard let plan else { return nil } + let weekStart = plan?.weekStartDate ?? Date().startOfWeek() + let slots = plan?.slotList ?? [] let locale = Locale(identifier: settings.languageEnum.resolved().localeIdentifier) let dishById = Dictionary(dishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) let dayFormatter = DateFormatter() @@ -76,12 +82,12 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable { dayFormatter.setLocalizedDateFormatFromTemplate("EEE d") let todayOffset = (Calendar.current.component(.weekday, from: Date()) + 5) % 7 - let isCurrentWeek = plan.weekStartDate == Date().startOfWeek() + let isCurrentWeek = weekStart == Date().startOfWeek() let schedule = settings.schedule(for: plan) let days: [WatchWeekPayload.Day] = schedule.dayRange.map { day in let meals: [WatchWeekPayload.Meal] = schedule.mealTypes.map { meal in - let slot = plan.slotList.first { $0.dayOfWeek == day && $0.mealType == meal.rawValue } + let slot = slots.first { $0.dayOfWeek == day && $0.mealType == meal.rawValue } let label = String(localized: String.LocalizationValue(meal.localizedKey)) var name: String? if let slot { @@ -98,7 +104,7 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable { } return WatchWeekPayload.Meal(type: meal.rawValue, label: label, name: name) } - let dayDate = plan.weekStartDate.addingDays(day) + let dayDate = weekStart.addingDays(day) return WatchWeekPayload.Day( dayOfWeek: day, title: dayFormatter.string(from: dayDate).capitalized(with: locale), @@ -111,12 +117,13 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable { } return WatchWeekPayload( - weekTitle: plan.weekStartDate.formattedWeekRange(), + weekTitle: weekStart.formattedWeekRange(), days: days, updatedAt: Date(), emptyMealText: String(localized: "watch_empty_meal"), emptyDayText: String(localized: "watch_empty_day"), - staleText: String(localized: "watch_stale") + staleText: String(localized: "watch_stale"), + updatedLabel: String(localized: "watch_updated") ) } diff --git a/MealMood/Services/WatchWeekPayload.swift b/MealMood/Services/WatchWeekPayload.swift index c5cd78b..8c4d634 100644 --- a/MealMood/Services/WatchWeekPayload.swift +++ b/MealMood/Services/WatchWeekPayload.swift @@ -56,6 +56,9 @@ struct WatchWeekPayload: Codable { var emptyMealText: String? var emptyDayText: String? var staleText: String? + /// "Updated" — shown next to `updatedAt` so it's visible at a glance when + /// the watch is holding an old snapshot. + var updatedLabel: String? static let appGroupID = "group.com.alexandrevazquez.mealmood" static let storageKey = "watch_week_payload_v1" diff --git a/MealMoodTests/WatchWeekPayloadTests.swift b/MealMoodTests/WatchWeekPayloadTests.swift index fc28f72..8279024 100644 --- a/MealMoodTests/WatchWeekPayloadTests.swift +++ b/MealMoodTests/WatchWeekPayloadTests.swift @@ -1,4 +1,5 @@ import XCTest +import SwiftData @testable import MealMood /// Regression suite for "the watch showed Wednesday's meal on Saturday". @@ -153,6 +154,35 @@ final class WatchWeekPayloadTests: XCTestCase { XCTAssertTrue(payload.isStale(now: saturday, calendar: calendar)) } + + // MARK: - A week with no plan yet + + @MainActor + func testWeekWithoutAPlanStillProducesASnapshot() throws { + // Staying quiet here is what left the watch showing the previous + // week: no plan meant no push at all, so the old snapshot survived. + let container = try ModelContainer( + for: AppSettings.self, Dish.self, Tag.self, WeekPlan.self, MealSlot.self, ShoppingItem.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + let context = ModelContext(container) + let settings = AppSettings() + settings.activeMealTypes = [.lunch, .dinner] + settings.includeWeekends = true + context.insert(settings) + + let payload = try XCTUnwrap( + WatchSyncService.makePayload(plan: nil, dishes: [], settings: settings), + "a missing plan must still produce the current week" + ) + + XCTAssertEqual(payload.days.count, 7) + XCTAssertNotNil(payload.currentDay(), "today must be in there") + XCTAssertTrue(payload.currentDay()?.isEmpty ?? false, "and it must read as nothing planned") + XCTAssertFalse(payload.isStale(), "an empty current week is not stale") + _ = container + } + func testWeekdayOffsetMapsMondayToZeroAndSundayToSix() { XCTAssertEqual(WatchWeekPayload.weekdayOffset(for: date(2026, 9, 7), calendar: calendar), 0) XCTAssertEqual(WatchWeekPayload.weekdayOffset(for: date(2026, 9, 12), calendar: calendar), 5) diff --git a/MealMoodWatch/TodayView.swift b/MealMoodWatch/TodayView.swift index b73efd8..83980a4 100644 --- a/MealMoodWatch/TodayView.swift +++ b/MealMoodWatch/TodayView.swift @@ -42,11 +42,34 @@ struct TodayView: View { } else { EmptySyncView() } + + // When the watch is holding an old snapshot, this is what makes + // it obvious instead of guessing why the meals look wrong. + if let payload = store.payload { + LastUpdatedFooter(payload: payload) + } } } } } +private struct LastUpdatedFooter: View { + let payload: WatchWeekPayload + + var body: some View { + HStack(spacing: 3) { + Image(systemName: "arrow.clockwise") + // Label comes localized from the iPhone; the date formats itself + // with the watch's own locale. + Text("\(payload.updatedLabel ?? "") \(payload.updatedAt.formatted(date: .abbreviated, time: .shortened))") + } + .font(.system(size: 9, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.top, 6) + } +} + struct MealCard: View { let meal: WatchWeekPayload.Meal /// Localized "nothing planned" coming from the iPhone — the watch bundle