From 1689e7cec29880b39ccdefddb4cdc6353e86f52d Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sun, 1 Feb 2026 17:20:25 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Andromida/App/State/RitualStore.swift | 41 +++++++- .../App/Views/Settings/SettingsView.swift | 83 ++++++++++++++++ Andromida/App/Views/Today/TodayView.swift | 17 +++- AndromidaWidget/Models/WidgetEntry.swift | 93 ++++++++++++++++++ .../Providers/AndromidaWidgetProvider.swift | 95 +++++++++++++++---- .../Views/AndromidaWidgetView.swift | 48 ++++++++++ PRD.md | 1 + 7 files changed, 357 insertions(+), 21 deletions(-) diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift index 19641df..4e01f8f 100644 --- a/Andromida/App/State/RitualStore.swift +++ b/Andromida/App/State/RitualStore.swift @@ -34,6 +34,19 @@ final class RitualStore: RitualStoreProviding { /// Ritual that needs renewal prompt (arc just completed) var ritualNeedingRenewal: Ritual? + /// The current time of day, updated periodically. Observable for UI refresh. + private(set) var currentTimeOfDay: TimeOfDay = TimeOfDay.current() + + /// Debug override for time of day (nil = use real time) + var debugTimeOfDayOverride: TimeOfDay? { + didSet { + updateCurrentTimeOfDay() + analyticsNeedsRefresh = true + insightCardsNeedRefresh = true + reloadRituals() + } + } + /// Rituals that have been dismissed for renewal this session private var dismissedRenewalRituals: Set = [] @@ -103,10 +116,27 @@ final class RitualStore: RitualStoreProviding { /// Refreshes rituals and derived state for current date/time. func refresh() { + updateCurrentTimeOfDay() reloadRituals() checkForCompletedArcs() } + /// Updates the current time of day and returns true if it changed. + @discardableResult + func updateCurrentTimeOfDay() -> Bool { + let newTimeOfDay = debugTimeOfDayOverride ?? TimeOfDay.current() + if newTimeOfDay != currentTimeOfDay { + currentTimeOfDay = newTimeOfDay + return true + } + return false + } + + /// Returns the effective time of day (considering debug override). + func effectiveTimeOfDay() -> TimeOfDay { + debugTimeOfDayOverride ?? TimeOfDay.current() + } + /// Refreshes rituals if the last refresh was beyond the minimum interval. func refreshIfNeeded(minimumInterval: TimeInterval = 5) { let now = Date() @@ -185,8 +215,17 @@ final class RitualStore: RitualStoreProviding { /// Returns rituals appropriate for the current time of day that have active arcs covering today. /// Filters based on time of day: morning (before 11am), midday (11am-2pm), afternoon (2pm-5pm), /// evening (5pm-9pm), night (after 9pm). Anytime rituals are always shown. + /// Uses the store's `currentTimeOfDay` which respects debug overrides. func ritualsForToday() -> [Ritual] { - RitualAnalytics.ritualsActive(on: Date(), from: currentRituals) + let today = Date() + let timeOfDay = effectiveTimeOfDay() + + return currentRituals.filter { ritual in + guard ritual.arcs?.first(where: { $0.isActive && $0.contains(date: today) }) != nil else { + return false + } + return ritual.timeOfDay == .anytime || ritual.timeOfDay == timeOfDay + } } /// Groups current rituals by time of day for display diff --git a/Andromida/App/Views/Settings/SettingsView.swift b/Andromida/App/Views/Settings/SettingsView.swift index d7e1abb..4d1cf54 100644 --- a/Andromida/App/Views/Settings/SettingsView.swift +++ b/Andromida/App/Views/Settings/SettingsView.swift @@ -172,6 +172,10 @@ struct SettingsView: View { ) { UserDefaults.standard.set(true, forKey: "debugForegroundRefreshNextForeground") } + + if let ritualStore { + TimeOfDayDebugPicker(store: ritualStore) + } } #endif @@ -240,6 +244,85 @@ extension SettingsView { } } +// MARK: - Debug Time of Day Picker + +#if DEBUG +/// Debug picker for simulating different times of day +private struct TimeOfDayDebugPicker: View { + @Bindable var store: RitualStore + + private var currentSelection: TimeOfDay? { + store.debugTimeOfDayOverride + } + + private var displayText: String { + if let override = store.debugTimeOfDayOverride { + return override.displayName + } + return String(localized: "Real Time (\(TimeOfDay.current().displayName))") + } + + var body: some View { + VStack(alignment: .leading, spacing: Design.Spacing.small) { + HStack { + Image(systemName: "clock.badge.questionmark") + .foregroundStyle(AppStatus.warning) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 2) { + Text(String(localized: "Simulate Time of Day")) + .font(.body) + .foregroundStyle(AppTextColors.primary) + + Text(displayText) + .font(.caption) + .foregroundStyle(AppTextColors.secondary) + } + + Spacer() + } + .padding(.vertical, Design.Spacing.small) + + // Time of day options + LazyVGrid(columns: [GridItem(.adaptive(minimum: 80))], spacing: Design.Spacing.small) { + // Real time option + Button { + store.debugTimeOfDayOverride = nil + } label: { + Text(String(localized: "Real")) + .font(.caption.weight(.medium)) + .foregroundStyle(currentSelection == nil ? AppTextColors.inverse : AppTextColors.primary) + .padding(.horizontal, Design.Spacing.small) + .padding(.vertical, Design.Spacing.xSmall) + .frame(maxWidth: .infinity) + .background(currentSelection == nil ? AppAccent.primary : AppSurface.secondary) + .clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.small)) + } + .buttonStyle(.plain) + + // Each time of day + ForEach(TimeOfDay.allCases, id: \.self) { time in + Button { + store.debugTimeOfDayOverride = time + } label: { + Text(time.displayName) + .font(.caption.weight(.medium)) + .foregroundStyle(currentSelection == time ? AppTextColors.inverse : AppTextColors.primary) + .padding(.horizontal, Design.Spacing.small) + .padding(.vertical, Design.Spacing.xSmall) + .frame(maxWidth: .infinity) + .background(currentSelection == time ? AppAccent.primary : AppSurface.secondary) + .clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.small)) + } + .buttonStyle(.plain) + } + } + } + .padding(.horizontal, Design.Spacing.medium) + } +} +#endif + #Preview { NavigationStack { SettingsView(store: SettingsStore.preview, ritualStore: nil) diff --git a/Andromida/App/Views/Today/TodayView.swift b/Andromida/App/Views/Today/TodayView.swift index 6e6979f..5cc925c 100644 --- a/Andromida/App/Views/Today/TodayView.swift +++ b/Andromida/App/Views/Today/TodayView.swift @@ -5,10 +5,14 @@ struct TodayView: View { @Bindable var store: RitualStore @Bindable var categoryStore: CategoryStore @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.scenePhase) private var scenePhase - /// Rituals to show now based on current time of day + /// Rituals to show now based on current time of day. + /// Depends on `store.currentTimeOfDay` which is observable. private var todayRituals: [Ritual] { - store.ritualsForToday() + // Access currentTimeOfDay to establish observation dependency + _ = store.currentTimeOfDay + return store.ritualsForToday() } /// Whether there are active rituals but none for the current time @@ -85,8 +89,17 @@ struct TodayView: View { } } .onAppear { + store.updateCurrentTimeOfDay() store.refreshIfNeeded() } + .onChange(of: scenePhase) { _, newPhase in + // Check for time-of-day changes when app becomes active + if newPhase == .active { + if store.updateCurrentTimeOfDay() { + store.refresh() + } + } + } } private func habitRows(for ritual: Ritual) -> [HabitRowModel] { diff --git a/AndromidaWidget/Models/WidgetEntry.swift b/AndromidaWidget/Models/WidgetEntry.swift index 9bcc2ef..ca50c67 100644 --- a/AndromidaWidget/Models/WidgetEntry.swift +++ b/AndromidaWidget/Models/WidgetEntry.swift @@ -13,3 +13,96 @@ struct WidgetEntry: TimelineEntry { let currentTimeOfDayRange: String let nextRitualInfo: String? } + +// MARK: - Preview Helpers + +extension WidgetEntry { + /// Creates a preview entry for a specific time of day + static func preview( + timeOfDay: String, + symbol: String, + range: String, + habits: [HabitEntry] = [], + completionRate: Double = 0.65, + streak: Int = 7, + nextRitual: String? = nil + ) -> WidgetEntry { + WidgetEntry( + date: Date(), + configuration: ConfigurationAppIntent(), + completionRate: completionRate, + currentStreak: streak, + nextHabits: habits, + weeklyTrend: [], + currentTimeOfDay: timeOfDay, + currentTimeOfDaySymbol: symbol, + currentTimeOfDayRange: range, + nextRitualInfo: nextRitual + ) + } + + /// Preview entries for each time of day + static let morningPreview = WidgetEntry.preview( + timeOfDay: "Morning", + symbol: "sunrise.fill", + range: "Before 11am", + habits: [ + HabitEntry(id: UUID(), title: "Morning Meditation", symbolName: "figure.mind.and.body", ritualTitle: "Mindfulness", isCompleted: false), + HabitEntry(id: UUID(), title: "Drink Water", symbolName: "drop.fill", ritualTitle: "Health", isCompleted: true), + HabitEntry(id: UUID(), title: "Take Vitamins", symbolName: "pill.fill", ritualTitle: "Health", isCompleted: false) + ], + nextRitual: "Next: Midday Movement (11am – 2pm)" + ) + + static let middayPreview = WidgetEntry.preview( + timeOfDay: "Midday", + symbol: "sun.max.fill", + range: "11am – 2pm", + habits: [ + HabitEntry(id: UUID(), title: "Midday Walk", symbolName: "figure.walk", ritualTitle: "Movement", isCompleted: false), + HabitEntry(id: UUID(), title: "Stretch Break", symbolName: "figure.flexibility", ritualTitle: "Movement", isCompleted: false) + ], + nextRitual: "Next: Deep Work (2pm – 5pm)" + ) + + static let afternoonPreview = WidgetEntry.preview( + timeOfDay: "Afternoon", + symbol: "sun.haze.fill", + range: "2pm – 5pm", + habits: [ + HabitEntry(id: UUID(), title: "Deep Focus Block", symbolName: "brain", ritualTitle: "Productivity", isCompleted: true), + HabitEntry(id: UUID(), title: "Clear Inbox", symbolName: "envelope.fill", ritualTitle: "Productivity", isCompleted: false) + ], + nextRitual: "Next: Evening Wind-Down (5pm – 9pm)" + ) + + static let eveningPreview = WidgetEntry.preview( + timeOfDay: "Evening", + symbol: "sunset.fill", + range: "5pm – 9pm", + habits: [ + HabitEntry(id: UUID(), title: "Evening Reflection", symbolName: "book.fill", ritualTitle: "Mindfulness", isCompleted: false), + HabitEntry(id: UUID(), title: "Gratitude Journal", symbolName: "heart.text.square.fill", ritualTitle: "Mindfulness", isCompleted: false) + ], + nextRitual: "Next: Sleep Prep (After 9pm)" + ) + + static let nightPreview = WidgetEntry.preview( + timeOfDay: "Night", + symbol: "moon.stars.fill", + range: "After 9pm", + habits: [ + HabitEntry(id: UUID(), title: "No Screens", symbolName: "iphone.slash", ritualTitle: "Sleep Prep", isCompleted: false), + HabitEntry(id: UUID(), title: "Read Book", symbolName: "book.closed.fill", ritualTitle: "Sleep Prep", isCompleted: false) + ], + nextRitual: "Next: Morning Routine (Tomorrow)" + ) + + static let emptyPreview = WidgetEntry.preview( + timeOfDay: "Afternoon", + symbol: "sun.haze.fill", + range: "2pm – 5pm", + habits: [], + nextRitual: "Next: Evening Wind-Down (5pm – 9pm)" + ) +} diff --git a/AndromidaWidget/Providers/AndromidaWidgetProvider.swift b/AndromidaWidget/Providers/AndromidaWidgetProvider.swift index 4631a70..3cf2a0e 100644 --- a/AndromidaWidget/Providers/AndromidaWidgetProvider.swift +++ b/AndromidaWidget/Providers/AndromidaWidgetProvider.swift @@ -23,17 +23,70 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider { } func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> WidgetEntry { - await fetchLatestData(for: configuration) + await fetchLatestData(for: configuration, at: Date()) } func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline { - let entry = await fetchLatestData(for: configuration) - let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date() - return Timeline(entries: [entry], policy: .after(nextUpdate)) + // Create entries for each time-of-day boundary for the next 24 hours. + // This ensures the widget displays the correct rituals at each time period. + var entries: [WidgetEntry] = [] + let calendar = Calendar.current + let now = Date() + + // Create an entry for right now + let currentEntry = await fetchLatestData(for: configuration, at: now) + entries.append(currentEntry) + + // Create entries at each upcoming time-of-day boundary + let boundaryDates = upcomingTimeOfDayBoundaries(from: now, count: 5) + for boundaryDate in boundaryDates { + let entry = await fetchLatestData(for: configuration, at: boundaryDate) + entries.append(entry) + } + + // Request a new timeline after the last entry (roughly 24 hours) + let lastEntryDate = entries.last?.date ?? now + let nextRefresh = calendar.date(byAdding: .hour, value: 1, to: lastEntryDate) ?? lastEntryDate + + return Timeline(entries: entries, policy: .after(nextRefresh)) + } + + /// Returns the next N time-of-day boundary dates. + /// Boundaries are at 11:00, 14:00, 17:00, 21:00, and 00:00. + private func upcomingTimeOfDayBoundaries(from startDate: Date, count: Int) -> [Date] { + let calendar = Calendar.current + let boundaryHours = [0, 11, 14, 17, 21] // midnight, morning end, midday end, afternoon end, evening end + + var boundaries: [Date] = [] + var checkDate = startDate + + while boundaries.count < count { + let currentHour = calendar.component(.hour, from: checkDate) + let startOfDay = calendar.startOfDay(for: checkDate) + + // Find the next boundary hour after the current hour + for hour in boundaryHours { + if hour > currentHour { + if let boundaryDate = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: startOfDay) { + boundaries.append(boundaryDate) + if boundaries.count >= count { break } + } + } + } + + // Move to the next day for remaining boundaries + if let nextDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) { + checkDate = nextDay + } else { + break + } + } + + return boundaries } @MainActor - private func fetchLatestData(for configuration: ConfigurationAppIntent) -> WidgetEntry { + private func fetchLatestData(for configuration: ConfigurationAppIntent, at targetDate: Date) -> WidgetEntry { let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self]) let configurationURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)? .appendingPathComponent("Andromida.sqlite") ?? URL.documentsDirectory.appendingPathComponent("Andromida.sqlite") @@ -47,21 +100,27 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider { let descriptor = FetchDescriptor() let rituals = try context.fetch(descriptor) - let today = Date() + // Use targetDate for time-of-day calculation, but today for completion data + let today = Calendar.current.startOfDay(for: targetDate) let dayID = RitualAnalytics.dayIdentifier(for: today) - let timeOfDay = TimeOfDay.current(for: today) + let timeOfDay = TimeOfDay.current(for: targetDate) - // Match the app's logic for "Today" view - let todayRituals = RitualAnalytics.ritualsActive(on: today, from: rituals) - .sorted { lhs, rhs in - if lhs.timeOfDay != rhs.timeOfDay { - return lhs.timeOfDay < rhs.timeOfDay - } - return lhs.sortIndex < rhs.sortIndex + // Filter rituals for the target time of day + let activeRituals = rituals.filter { ritual in + guard ritual.arcs?.first(where: { $0.isActive && $0.contains(date: today) }) != nil else { + return false } + return ritual.timeOfDay == .anytime || ritual.timeOfDay == timeOfDay + } + .sorted { lhs, rhs in + if lhs.timeOfDay != rhs.timeOfDay { + return lhs.timeOfDay < rhs.timeOfDay + } + return lhs.sortIndex < rhs.sortIndex + } var visibleHabits: [HabitEntry] = [] - for ritual in todayRituals { + for ritual in activeRituals { if let arc = ritual.arcs?.first(where: { $0.isActive && $0.contains(date: today) }) { // Sort habits within each ritual by their sortIndex let sortedHabits = (arc.habits ?? []).sorted { $0.sortIndex < $1.sortIndex } @@ -88,8 +147,8 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider { // Next ritual info var nextRitualString: String? = nil - if let nextRitual = RitualAnalytics.nextUpcomingRitual(from: rituals, currentDate: today) { - let isTomorrow = !Calendar.current.isDate(nextRitual.startDate, inSameDayAs: today) + if let nextRitual = RitualAnalytics.nextUpcomingRitual(from: rituals, currentDate: targetDate) { + let isTomorrow = !Calendar.current.isDate(nextRitual.startDate, inSameDayAs: targetDate) if isTomorrow { let format = String(localized: "Next ritual: Tomorrow %@ (%@)") nextRitualString = String.localizedStringWithFormat( @@ -108,7 +167,7 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider { } return WidgetEntry( - date: today, + date: targetDate, configuration: configuration, completionRate: overallRate, currentStreak: streak, diff --git a/AndromidaWidget/Views/AndromidaWidgetView.swift b/AndromidaWidget/Views/AndromidaWidgetView.swift index 6c3a750..f34f568 100644 --- a/AndromidaWidget/Views/AndromidaWidgetView.swift +++ b/AndromidaWidget/Views/AndromidaWidgetView.swift @@ -106,3 +106,51 @@ extension Color { static let brandingPrimary = Color(red: 0.12, green: 0.09, blue: 0.08) static let brandingAccent = Color(red: 0.95, green: 0.60, blue: 0.45) // Matches the orange-ish accent in your app } + +// MARK: - Previews for Testing Time-of-Day Changes + +#Preview("Morning", as: .systemMedium) { + AndromidaWidget() +} timeline: { + WidgetEntry.morningPreview +} + +#Preview("Midday", as: .systemMedium) { + AndromidaWidget() +} timeline: { + WidgetEntry.middayPreview +} + +#Preview("Afternoon", as: .systemMedium) { + AndromidaWidget() +} timeline: { + WidgetEntry.afternoonPreview +} + +#Preview("Evening", as: .systemMedium) { + AndromidaWidget() +} timeline: { + WidgetEntry.eveningPreview +} + +#Preview("Night", as: .systemMedium) { + AndromidaWidget() +} timeline: { + WidgetEntry.nightPreview +} + +#Preview("Empty State", as: .systemMedium) { + AndromidaWidget() +} timeline: { + WidgetEntry.emptyPreview +} + +#Preview("Large - All Times", as: .systemLarge) { + AndromidaWidget() +} timeline: { + WidgetEntry.morningPreview + WidgetEntry.middayPreview + WidgetEntry.afternoonPreview + WidgetEntry.eveningPreview + WidgetEntry.nightPreview +} diff --git a/PRD.md b/PRD.md index d1781d7..575852d 100644 --- a/PRD.md +++ b/PRD.md @@ -597,3 +597,4 @@ Andromida/ | Version | Date | Description | |---------|------|-------------| | 1.0 | February 2026 | Initial PRD based on implemented features | +| 1.1 | February 2026 | Fixed time-of-day refresh bug in Today view and Widget; added debug time simulation |