watch: enviar la semana aunque todavia no este planificada

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246
This commit is contained in:
alexandrev-tibco
2026-09-12 16:32:34 +02:00
parent 649fe6d453
commit 4579b9bb69
10 changed files with 76 additions and 7 deletions
@@ -483,3 +483,4 @@
"watch_empty_meal" = "Nicht geplant"; "watch_empty_meal" = "Nicht geplant";
"watch_empty_day" = "Für heute ist nichts geplant"; "watch_empty_day" = "Für heute ist nichts geplant";
"watch_stale" = "Öffne MealMood auf dem iPhone, um diese Woche zu sehen"; "watch_stale" = "Öffne MealMood auf dem iPhone, um diese Woche zu sehen";
"watch_updated" = "Aktualisiert";
@@ -483,3 +483,4 @@
"watch_empty_meal" = "Not planned"; "watch_empty_meal" = "Not planned";
"watch_empty_day" = "Nothing planned for today"; "watch_empty_day" = "Nothing planned for today";
"watch_stale" = "Open MealMood on your iPhone to see this week"; "watch_stale" = "Open MealMood on your iPhone to see this week";
"watch_updated" = "Updated";
@@ -483,3 +483,4 @@
"watch_empty_meal" = "Sin planificar"; "watch_empty_meal" = "Sin planificar";
"watch_empty_day" = "Hoy no hay nada planificado"; "watch_empty_day" = "Hoy no hay nada planificado";
"watch_stale" = "Abre MealMood en el iPhone para ver esta semana"; "watch_stale" = "Abre MealMood en el iPhone para ver esta semana";
"watch_updated" = "Actualizado";
@@ -483,3 +483,4 @@
"watch_empty_meal" = "Non planifié"; "watch_empty_meal" = "Non planifié";
"watch_empty_day" = "Rien de prévu aujourd'hui"; "watch_empty_day" = "Rien de prévu aujourd'hui";
"watch_stale" = "Ouvrez MealMood sur l'iPhone pour voir cette semaine"; "watch_stale" = "Ouvrez MealMood sur l'iPhone pour voir cette semaine";
"watch_updated" = "Mis à jour";
@@ -483,3 +483,4 @@
"watch_empty_meal" = "Non pianificato"; "watch_empty_meal" = "Non pianificato";
"watch_empty_day" = "Oggi non c'è niente di pianificato"; "watch_empty_day" = "Oggi non c'è niente di pianificato";
"watch_stale" = "Apri MealMood sull'iPhone per vedere questa settimana"; "watch_stale" = "Apri MealMood sull'iPhone per vedere questa settimana";
"watch_updated" = "Aggiornato";
@@ -483,3 +483,4 @@
"watch_empty_meal" = "Sem planejamento"; "watch_empty_meal" = "Sem planejamento";
"watch_empty_day" = "Nada planejado para hoje"; "watch_empty_day" = "Nada planejado para hoje";
"watch_stale" = "Abra o MealMood no iPhone para ver esta semana"; "watch_stale" = "Abra o MealMood no iPhone para ver esta semana";
"watch_updated" = "Atualizado";
+14 -7
View File
@@ -46,7 +46,8 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable {
let descriptor = FetchDescriptor<WeekPlan>( let descriptor = FetchDescriptor<WeekPlan>(
predicate: #Predicate<WeekPlan> { plan in plan.weekStartDate == weekStart } predicate: #Predicate<WeekPlan> { 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<Dish>())) ?? [] let dishes = (try? context.fetch(FetchDescriptor<Dish>())) ?? []
return Self.makePayload(plan: plan, dishes: dishes, settings: settings)?.encoded() 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]) 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? { 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 locale = Locale(identifier: settings.languageEnum.resolved().localeIdentifier)
let dishById = Dictionary(dishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) let dishById = Dictionary(dishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let dayFormatter = DateFormatter() let dayFormatter = DateFormatter()
@@ -76,12 +82,12 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable {
dayFormatter.setLocalizedDateFormatFromTemplate("EEE d") dayFormatter.setLocalizedDateFormatFromTemplate("EEE d")
let todayOffset = (Calendar.current.component(.weekday, from: Date()) + 5) % 7 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 schedule = settings.schedule(for: plan)
let days: [WatchWeekPayload.Day] = schedule.dayRange.map { day in let days: [WatchWeekPayload.Day] = schedule.dayRange.map { day in
let meals: [WatchWeekPayload.Meal] = schedule.mealTypes.map { meal 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)) let label = String(localized: String.LocalizationValue(meal.localizedKey))
var name: String? var name: String?
if let slot { if let slot {
@@ -98,7 +104,7 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable {
} }
return WatchWeekPayload.Meal(type: meal.rawValue, label: label, name: name) return WatchWeekPayload.Meal(type: meal.rawValue, label: label, name: name)
} }
let dayDate = plan.weekStartDate.addingDays(day) let dayDate = weekStart.addingDays(day)
return WatchWeekPayload.Day( return WatchWeekPayload.Day(
dayOfWeek: day, dayOfWeek: day,
title: dayFormatter.string(from: dayDate).capitalized(with: locale), title: dayFormatter.string(from: dayDate).capitalized(with: locale),
@@ -111,12 +117,13 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable {
} }
return WatchWeekPayload( return WatchWeekPayload(
weekTitle: plan.weekStartDate.formattedWeekRange(), weekTitle: weekStart.formattedWeekRange(),
days: days, days: days,
updatedAt: Date(), updatedAt: Date(),
emptyMealText: String(localized: "watch_empty_meal"), emptyMealText: String(localized: "watch_empty_meal"),
emptyDayText: String(localized: "watch_empty_day"), emptyDayText: String(localized: "watch_empty_day"),
staleText: String(localized: "watch_stale") staleText: String(localized: "watch_stale"),
updatedLabel: String(localized: "watch_updated")
) )
} }
+3
View File
@@ -56,6 +56,9 @@ struct WatchWeekPayload: Codable {
var emptyMealText: String? var emptyMealText: String?
var emptyDayText: String? var emptyDayText: String?
var staleText: 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 appGroupID = "group.com.alexandrevazquez.mealmood"
static let storageKey = "watch_week_payload_v1" static let storageKey = "watch_week_payload_v1"
+30
View File
@@ -1,4 +1,5 @@
import XCTest import XCTest
import SwiftData
@testable import MealMood @testable import MealMood
/// Regression suite for "the watch showed Wednesday's meal on Saturday". /// 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)) 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() { func testWeekdayOffsetMapsMondayToZeroAndSundayToSix() {
XCTAssertEqual(WatchWeekPayload.weekdayOffset(for: date(2026, 9, 7), calendar: calendar), 0) XCTAssertEqual(WatchWeekPayload.weekdayOffset(for: date(2026, 9, 7), calendar: calendar), 0)
XCTAssertEqual(WatchWeekPayload.weekdayOffset(for: date(2026, 9, 12), calendar: calendar), 5) XCTAssertEqual(WatchWeekPayload.weekdayOffset(for: date(2026, 9, 12), calendar: calendar), 5)
+23
View File
@@ -42,11 +42,34 @@ struct TodayView: View {
} else { } else {
EmptySyncView() 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 { struct MealCard: View {
let meal: WatchWeekPayload.Meal let meal: WatchWeekPayload.Meal
/// Localized "nothing planned" coming from the iPhone the watch bundle /// Localized "nothing planned" coming from the iPhone the watch bundle