watch: semanas navegables y los dias que no se planifican

Dos cosas que faltaban, las dos visibles en domingo con el fin de semana
desactivado:

El envio al reloj solo llevaba la semana en curso, asi que no habia forma de
ver las semanas ya planificadas por delante. Ahora viajan cuatro (la anterior,
la actual y las dos siguientes) y la vista de semana se desliza entre ellas,
arrancando en la actual.

Y un dia fuera del plan dejaba al reloj sin nada que enseñar: el domingo no
existe en el payload si no planificas fines de semana, asi que la complicacion
salia vacia y la app decia "abre MealMood en el iPhone", como si fuera un
problema de sincronizacion. Ahora se distingue: si hoy no se planifica se dice,
y se enseña el proximo dia que si tiene comidas — que es lo util en el reloj.

De paso, las semanas se comparan con el calendario alineado a lunes. Con el del
sistema, en las regiones donde la semana empieza en domingo, un domingo caia en
otra semana que su propio lunes y un envio recien hecho parecia caducado.

Refs #36, #37

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-13 21:48:24 +02:00
parent 4008eb7fa0
commit bc684a099c
13 changed files with 396 additions and 82 deletions
@@ -484,3 +484,5 @@
"watch_empty_day" = "Für heute ist nichts geplant";
"watch_stale" = "Öffne MealMood auf dem iPhone, um diese Woche zu sehen";
"watch_updated" = "Aktualisiert";
"watch_not_planned_today" = "Heute planst du keine Mahlzeiten";
"watch_next_up" = "Als Nächstes";
@@ -484,3 +484,5 @@
"watch_empty_day" = "Nothing planned for today";
"watch_stale" = "Open MealMood on your iPhone to see this week";
"watch_updated" = "Updated";
"watch_not_planned_today" = "You don't plan meals today";
"watch_next_up" = "Next up";
@@ -484,3 +484,5 @@
"watch_empty_day" = "Hoy no hay nada planificado";
"watch_stale" = "Abre MealMood en el iPhone para ver esta semana";
"watch_updated" = "Actualizado";
"watch_not_planned_today" = "Hoy no planificas comidas";
"watch_next_up" = "A continuación";
@@ -484,3 +484,5 @@
"watch_empty_day" = "Rien de prévu aujourd'hui";
"watch_stale" = "Ouvrez MealMood sur l'iPhone pour voir cette semaine";
"watch_updated" = "Mis à jour";
"watch_not_planned_today" = "Aujourd'hui vous ne planifiez pas";
"watch_next_up" = "À suivre";
@@ -484,3 +484,5 @@
"watch_empty_day" = "Oggi non c'è niente di pianificato";
"watch_stale" = "Apri MealMood sull'iPhone per vedere questa settimana";
"watch_updated" = "Aggiornato";
"watch_not_planned_today" = "Oggi non pianifichi pasti";
"watch_next_up" = "Prossimamente";
@@ -484,3 +484,5 @@
"watch_empty_day" = "Nada planejado para hoje";
"watch_stale" = "Abra o MealMood no iPhone para ver esta semana";
"watch_updated" = "Atualizado";
"watch_not_planned_today" = "Hoje você não planeja refeições";
"watch_next_up" = "A seguir";
+73 -25
View File
@@ -42,20 +42,15 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable {
let context = ModelContext(modelContainer)
guard let settings = try? context.fetch(FetchDescriptor<AppSettings>()).first else { return nil }
let weekStart = Date().startOfWeek()
let descriptor = FetchDescriptor<WeekPlan>(
predicate: #Predicate<WeekPlan> { plan in plan.weekStartDate == weekStart }
)
// No plan for this week yet? Answer with the empty week anyway.
let plan = try? context.fetch(descriptor).first
let plans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
let dishes = (try? context.fetch(FetchDescriptor<Dish>())) ?? []
return Self.makePayload(plan: plan, dishes: dishes, settings: settings)?.encoded()
return Self.makePayload(plans: plans, dishes: dishes, settings: settings)?.encoded()
}
func push(plan: WeekPlan?, dishes: [Dish], settings: AppSettings) {
func push(plans: [WeekPlan], dishes: [Dish], settings: AppSettings) {
guard WCSession.isSupported() else { return }
guard let payload = Self.makePayload(plan: plan, dishes: dishes, settings: settings),
guard let payload = Self.makePayload(plans: plans, dishes: dishes, settings: settings),
let data = payload.encoded() else { return }
send(data)
}
@@ -73,19 +68,82 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable {
/// 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? {
let weekStart = plan?.weekStartDate ?? Date().startOfWeek()
let slots = plan?.slotList ?? []
makePayload(plans: [plan].compactMap { $0 }, dishes: dishes, settings: settings)
}
/// Weeks sent to the watch: the previous one, the current one and the two
/// ahead. Sending only the current week is why the watch couldn't move
/// between weeks it simply had nothing else.
static let weekOffsets = [-1, 0, 1, 2]
static func makePayload(
plans: [WeekPlan],
dishes: [Dish],
settings: AppSettings,
now: Date = Date()
) -> WatchWeekPayload? {
let locale = Locale(identifier: settings.languageEnum.resolved().localeIdentifier)
let dishById = Dictionary(dishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let dayFormatter = DateFormatter()
dayFormatter.locale = locale
dayFormatter.setLocalizedDateFormatFromTemplate("EEE d")
let todayOffset = (Calendar.current.component(.weekday, from: Date()) + 5) % 7
let isCurrentWeek = weekStart == Date().startOfWeek()
let schedule = settings.schedule(for: plan)
let currentWeekStart = now.startOfWeek()
let planByWeek = Dictionary(
plans.map { ($0.weekStartDate, $0) },
uniquingKeysWith: { first, _ in first }
)
let days: [WatchWeekPayload.Day] = schedule.dayRange.map { day in
let weeks: [WatchWeekPayload.Week] = weekOffsets.map { offset in
let weekStart = currentWeekStart.addingDays(offset * 7)
let plan = planByWeek[weekStart]
return WatchWeekPayload.Week(
weekStartDate: weekStart,
weekTitle: weekStart.formattedWeekRange(),
days: days(
weekStart: weekStart,
plan: plan,
settings: settings,
dishById: dishById,
dayFormatter: dayFormatter,
locale: locale,
now: now
)
)
}
guard let currentWeek = weeks.first(where: { $0.weekStartDate == currentWeekStart }) else { return nil }
return WatchWeekPayload(
weekTitle: currentWeek.weekTitle,
days: currentWeek.days,
updatedAt: now,
weekStartDate: currentWeekStart,
weeks: weeks,
emptyMealText: String(localized: "watch_empty_meal"),
emptyDayText: String(localized: "watch_empty_day"),
staleText: String(localized: "watch_stale"),
updatedLabel: String(localized: "watch_updated"),
notPlannedTodayText: String(localized: "watch_not_planned_today"),
nextUpLabel: String(localized: "watch_next_up")
)
}
private static func days(
weekStart: Date,
plan: WeekPlan?,
settings: AppSettings,
dishById: [UUID: Dish],
dayFormatter: DateFormatter,
locale: Locale,
now: Date
) -> [WatchWeekPayload.Day] {
let slots = plan?.slotList ?? []
let schedule = settings.schedule(for: plan)
let todayOffset = (Calendar.current.component(.weekday, from: now) + 5) % 7
let isCurrentWeek = weekStart == now.startOfWeek()
return schedule.dayRange.map { day in
let meals: [WatchWeekPayload.Meal] = schedule.mealTypes.map { meal in
let slot = slots.first { $0.dayOfWeek == day && $0.mealType == meal.rawValue }
let label = String(localized: String.LocalizationValue(meal.localizedKey))
@@ -115,16 +173,6 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable {
meals: meals
)
}
return WatchWeekPayload(
weekTitle: weekStart.formattedWeekRange(),
days: days,
updatedAt: Date(),
emptyMealText: String(localized: "watch_empty_meal"),
emptyDayText: String(localized: "watch_empty_day"),
staleText: String(localized: "watch_stale"),
updatedLabel: String(localized: "watch_updated")
)
}
// MARK: - WCSessionDelegate
+102 -2
View File
@@ -11,6 +11,23 @@ struct WatchWeekPayload: Codable {
let name: String? // dish (+ secondary), "Comer fuera", or nil if empty
}
struct Week: Codable, Identifiable {
let weekStartDate: Date
let weekTitle: String
let days: [Day]
var id: Date { weekStartDate }
func isCurrentWeek(now: Date = Date(), calendar: Calendar = .current) -> Bool {
WatchWeekPayload.weekAligned(calendar).isDate(weekStartDate, equalTo: now, toGranularity: .weekOfYear)
}
/// Days with at least one meal planned what's worth showing.
var plannedDays: [Day] {
days.filter { !$0.isEmpty }
}
}
struct Day: Codable {
let dayOfWeek: Int // 0=Monday 6=Sunday
let title: String // localized short title ("Lun 8")
@@ -51,6 +68,15 @@ struct WatchWeekPayload: Codable {
let days: [Day]
let updatedAt: Date
/// Monday of the week `days` belongs to. Optional: snapshots written before
/// 2.1.2 don't carry it.
var weekStartDate: Date?
/// Every week the phone sent (previous, current and the next ones), so the
/// watch can move between them. nil in older snapshots, which only ever
/// carried the current week in `days`.
var weeks: [Week]?
/// Localized texts the watch cannot build on its own its bundle carries no
/// strings. Optional so older snapshots still decode.
var emptyMealText: String?
@@ -59,6 +85,11 @@ struct WatchWeekPayload: Codable {
/// "Updated" shown next to `updatedAt` so it's visible at a glance when
/// the watch is holding an old snapshot.
var updatedLabel: String?
/// "Today you don't plan meals" for the days left out of the plan
/// (weekends turned off), which are not the same as being out of date.
var notPlannedTodayText: String?
/// "Next" heading for the upcoming day shown instead.
var nextUpLabel: String?
static let appGroupID = "group.com.alexandrevazquez.mealmood"
static let storageKey = "watch_week_payload_v1"
@@ -79,6 +110,16 @@ struct WatchWeekPayload: Codable {
UserDefaults(suiteName: Self.appGroupID)?.set(encoded(), forKey: Self.storageKey)
}
/// The app defines a week as MondaySunday everywhere (`Date.startOfWeek`),
/// but `Calendar.current.firstWeekday` is Sunday in some regions. Comparing
/// weeks with the raw system calendar would put a Sunday in a different week
/// from its own Monday and make a perfectly current snapshot look stale.
static func weekAligned(_ calendar: Calendar) -> Calendar {
var aligned = calendar
aligned.firstWeekday = 2
return aligned
}
/// 0=Monday 6=Sunday, matching `MealSlot.dayOfWeek`.
static func weekdayOffset(for date: Date, calendar: Calendar = .current) -> Int {
(calendar.component(.weekday, from: date) + 5) % 7
@@ -95,7 +136,7 @@ struct WatchWeekPayload: Codable {
// only handle and it's trustworthy only while the snapshot itself
// belongs to the current week. Otherwise last week's Saturday would
// pass for today.
guard calendar.isDate(updatedAt, equalTo: now, toGranularity: .weekOfYear) else { return nil }
guard Self.weekAligned(calendar).isDate(updatedAt, equalTo: now, toGranularity: .weekOfYear) else { return nil }
let offset = Self.weekdayOffset(for: now, calendar: calendar)
return days.first { $0.dayOfWeek == offset }
}
@@ -103,7 +144,55 @@ struct WatchWeekPayload: Codable {
/// True when the snapshot no longer covers today, so showing any of its days
/// would be showing the wrong meal.
func isStale(now: Date = Date(), calendar: Calendar = .current) -> Bool {
currentDay(now: now, calendar: calendar) == nil
if case .outOfDate = todayState(now: now, calendar: calendar) { return true }
return false
}
/// Whether the snapshot describes the week we're living in regardless of
/// whether today itself is one of the planned days.
func coversCurrentWeek(now: Date = Date(), calendar: Calendar = .current) -> Bool {
let aligned = Self.weekAligned(calendar)
if let weekStartDate {
return aligned.isDate(weekStartDate, equalTo: now, toGranularity: .weekOfYear)
}
if days.contains(where: { $0.date != nil }) {
return days.contains { aligned.isDate($0.date!, equalTo: now, toGranularity: .weekOfYear) }
}
return aligned.isDate(updatedAt, equalTo: now, toGranularity: .weekOfYear)
}
/// What the watch should be showing right now.
enum TodayState {
/// Today is planned here are its meals.
case planned(Day)
/// Today is deliberately not planned (weekends off). Shows the next day
/// that does have meals, when there is one.
case notPlannedToday(next: Day?)
/// The snapshot is from another week: anything in it would be wrong.
case outOfDate
}
func todayState(now: Date = Date(), calendar: Calendar = .current) -> TodayState {
if let today = currentDay(now: now, calendar: calendar) {
return .planned(today)
}
guard coversCurrentWeek(now: now, calendar: calendar) else { return .outOfDate }
return .notPlannedToday(next: upcomingDay(now: now, calendar: calendar))
}
/// The next day with something planned, looking across every week the phone
/// sent so a Sunday points at Monday's dinner instead of showing nothing.
func upcomingDay(now: Date = Date(), calendar: Calendar = .current) -> Day? {
let startOfToday = calendar.startOfDay(for: now)
let candidates = (weeks?.flatMap(\.days) ?? days)
.filter { !$0.isEmpty }
.compactMap { day -> (Date, Day)? in
guard let date = day.date else { return nil }
return (date, day)
}
.filter { $0.0 >= startOfToday }
.sorted { $0.0 < $1.0 }
return candidates.first?.1
}
static func icon(for mealType: String) -> String {
@@ -116,3 +205,14 @@ struct WatchWeekPayload: Codable {
}
}
}
extension Date {
/// Monday of this date's week. The watch targets don't compile the iOS
/// `Date+Helpers`, and this is the only piece of it they need.
func startOfWeekOnWatch(calendar: Calendar = .current) -> Date {
var cal = calendar
cal.firstWeekday = 2 // Monday
let components = cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: self)
return cal.date(from: components) ?? self
}
}
+3 -2
View File
@@ -1153,10 +1153,11 @@ struct HomeView: View {
WidgetDataStore.update(plan: todayPlan, dishes: dishes, settings: settings)
// Week snapshot: stored locally for the iOS week widget and pushed to
// the watch app/complication via WatchConnectivity.
if let payload = WatchSyncService.makePayload(plan: todayPlan, dishes: dishes, settings: settings) {
// The watch gets the neighbouring weeks too, so it can move between them.
if let payload = WatchSyncService.makePayload(plans: weekPlans, dishes: dishes, settings: settings) {
payload.store()
}
WatchSyncService.shared.push(plan: todayPlan, dishes: dishes, settings: settings)
WatchSyncService.shared.push(plans: weekPlans, dishes: dishes, settings: settings)
}
@ViewBuilder