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:
@@ -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";
|
||||||
|
|||||||
@@ -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")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user