Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-01 17:20:25 -06:00
parent 94e311ac47
commit 1689e7cec2
7 changed files with 357 additions and 21 deletions

View File

@ -34,6 +34,19 @@ final class RitualStore: RitualStoreProviding {
/// Ritual that needs renewal prompt (arc just completed) /// Ritual that needs renewal prompt (arc just completed)
var ritualNeedingRenewal: Ritual? 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 /// Rituals that have been dismissed for renewal this session
private var dismissedRenewalRituals: Set<PersistentIdentifier> = [] private var dismissedRenewalRituals: Set<PersistentIdentifier> = []
@ -103,10 +116,27 @@ final class RitualStore: RitualStoreProviding {
/// Refreshes rituals and derived state for current date/time. /// Refreshes rituals and derived state for current date/time.
func refresh() { func refresh() {
updateCurrentTimeOfDay()
reloadRituals() reloadRituals()
checkForCompletedArcs() 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. /// Refreshes rituals if the last refresh was beyond the minimum interval.
func refreshIfNeeded(minimumInterval: TimeInterval = 5) { func refreshIfNeeded(minimumInterval: TimeInterval = 5) {
let now = Date() 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. /// 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), /// 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. /// evening (5pm-9pm), night (after 9pm). Anytime rituals are always shown.
/// Uses the store's `currentTimeOfDay` which respects debug overrides.
func ritualsForToday() -> [Ritual] { 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 /// Groups current rituals by time of day for display

View File

@ -172,6 +172,10 @@ struct SettingsView: View {
) { ) {
UserDefaults.standard.set(true, forKey: "debugForegroundRefreshNextForeground") UserDefaults.standard.set(true, forKey: "debugForegroundRefreshNextForeground")
} }
if let ritualStore {
TimeOfDayDebugPicker(store: ritualStore)
}
} }
#endif #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 { #Preview {
NavigationStack { NavigationStack {
SettingsView(store: SettingsStore.preview, ritualStore: nil) SettingsView(store: SettingsStore.preview, ritualStore: nil)

View File

@ -5,10 +5,14 @@ struct TodayView: View {
@Bindable var store: RitualStore @Bindable var store: RitualStore
@Bindable var categoryStore: CategoryStore @Bindable var categoryStore: CategoryStore
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @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] { 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 /// Whether there are active rituals but none for the current time
@ -85,8 +89,17 @@ struct TodayView: View {
} }
} }
.onAppear { .onAppear {
store.updateCurrentTimeOfDay()
store.refreshIfNeeded() 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] { private func habitRows(for ritual: Ritual) -> [HabitRowModel] {

View File

@ -13,3 +13,96 @@ struct WidgetEntry: TimelineEntry {
let currentTimeOfDayRange: String let currentTimeOfDayRange: String
let nextRitualInfo: 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)"
)
}

View File

@ -23,17 +23,70 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
} }
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> WidgetEntry { 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<WidgetEntry> { func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<WidgetEntry> {
let entry = await fetchLatestData(for: configuration) // Create entries for each time-of-day boundary for the next 24 hours.
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date() // This ensures the widget displays the correct rituals at each time period.
return Timeline(entries: [entry], policy: .after(nextUpdate)) 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 @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 schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
let configurationURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)? let configurationURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)?
.appendingPathComponent("Andromida.sqlite") ?? URL.documentsDirectory.appendingPathComponent("Andromida.sqlite") .appendingPathComponent("Andromida.sqlite") ?? URL.documentsDirectory.appendingPathComponent("Andromida.sqlite")
@ -47,21 +100,27 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
let descriptor = FetchDescriptor<Ritual>() let descriptor = FetchDescriptor<Ritual>()
let rituals = try context.fetch(descriptor) 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 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 // Filter rituals for the target time of day
let todayRituals = RitualAnalytics.ritualsActive(on: today, from: rituals) let activeRituals = rituals.filter { ritual in
.sorted { lhs, rhs in guard ritual.arcs?.first(where: { $0.isActive && $0.contains(date: today) }) != nil else {
if lhs.timeOfDay != rhs.timeOfDay { return false
return lhs.timeOfDay < rhs.timeOfDay
}
return lhs.sortIndex < rhs.sortIndex
} }
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] = [] var visibleHabits: [HabitEntry] = []
for ritual in todayRituals { for ritual in activeRituals {
if let arc = ritual.arcs?.first(where: { $0.isActive && $0.contains(date: today) }) { if let arc = ritual.arcs?.first(where: { $0.isActive && $0.contains(date: today) }) {
// Sort habits within each ritual by their sortIndex // Sort habits within each ritual by their sortIndex
let sortedHabits = (arc.habits ?? []).sorted { $0.sortIndex < $1.sortIndex } let sortedHabits = (arc.habits ?? []).sorted { $0.sortIndex < $1.sortIndex }
@ -88,8 +147,8 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
// Next ritual info // Next ritual info
var nextRitualString: String? = nil var nextRitualString: String? = nil
if let nextRitual = RitualAnalytics.nextUpcomingRitual(from: rituals, currentDate: today) { if let nextRitual = RitualAnalytics.nextUpcomingRitual(from: rituals, currentDate: targetDate) {
let isTomorrow = !Calendar.current.isDate(nextRitual.startDate, inSameDayAs: today) let isTomorrow = !Calendar.current.isDate(nextRitual.startDate, inSameDayAs: targetDate)
if isTomorrow { if isTomorrow {
let format = String(localized: "Next ritual: Tomorrow %@ (%@)") let format = String(localized: "Next ritual: Tomorrow %@ (%@)")
nextRitualString = String.localizedStringWithFormat( nextRitualString = String.localizedStringWithFormat(
@ -108,7 +167,7 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
} }
return WidgetEntry( return WidgetEntry(
date: today, date: targetDate,
configuration: configuration, configuration: configuration,
completionRate: overallRate, completionRate: overallRate,
currentStreak: streak, currentStreak: streak,

View File

@ -106,3 +106,51 @@ extension Color {
static let brandingPrimary = Color(red: 0.12, green: 0.09, blue: 0.08) 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 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
}

1
PRD.md
View File

@ -597,3 +597,4 @@ Andromida/
| Version | Date | Description | | Version | Date | Description |
|---------|------|-------------| |---------|------|-------------|
| 1.0 | February 2026 | Initial PRD based on implemented features | | 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 |