diff --git a/Andromida.xcodeproj/project.pbxproj b/Andromida.xcodeproj/project.pbxproj
index 2abe565..f8e0401 100644
--- a/Andromida.xcodeproj/project.pbxproj
+++ b/Andromida.xcodeproj/project.pbxproj
@@ -408,6 +408,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = Andromida/Andromida.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
@@ -441,6 +442,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = Andromida/Andromida.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
diff --git a/Andromida/Andromida.entitlements b/Andromida/Andromida.entitlements
index c280ba7..d1853b1 100644
--- a/Andromida/Andromida.entitlements
+++ b/Andromida/Andromida.entitlements
@@ -4,7 +4,9 @@
com.apple.developer.icloud-container-identifiers
- com.apple.developer.ubiquity-kvstore-identifier
- $(TeamIdentifierPrefix)$(CFBundleIdentifier)
+ com.apple.developer.icloud-services
+
+ CloudKit
+
diff --git a/Andromida/App/Localization/Localizable.xcstrings b/Andromida/App/Localization/Localizable.xcstrings
index 92ba7ed..35d7e26 100644
--- a/Andromida/App/Localization/Localizable.xcstrings
+++ b/Andromida/App/Localization/Localizable.xcstrings
@@ -3,6 +3,22 @@
"strings" : {
"" : {
+ },
+ "%lld" : {
+ "comment" : "A text view displaying the day number in a history calendar cell. The text is centered and has a small font size.",
+ "isCommentAutoGenerated" : true
+ },
+ "%lld of %lld" : {
+ "comment" : "A title that shows the number of habits completed on a specific day, followed by a label that describes what that number represents. The first argument is the count of completed habits. The second argument is a string that describes the nature of the habits being counted.",
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "%1$lld of %2$lld"
+ }
+ }
+ }
},
"%lld of %lld habits complete" : {
"localizations" : {
@@ -26,6 +42,18 @@
}
}
},
+ "%lld percent complete" : {
+ "comment" : "A label describing the completion percentage of a task. The argument is the percentage.",
+ "isCommentAutoGenerated" : true
+ },
+ "%lld%%" : {
+ "comment" : "A text label displaying a percentage. The argument is a value between 0.0 and 1.0.",
+ "isCommentAutoGenerated" : true
+ },
+ "7-Day Trend" : {
+ "comment" : "A heading for the 7-day trend section of an insight detail sheet.",
+ "isCommentAutoGenerated" : true
+ },
"A fresh ritual created from your focus today." : {
"localizations" : {
"en" : {
@@ -97,6 +125,7 @@
}
},
"Across all rituals" : {
+ "extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -118,7 +147,12 @@
}
}
},
+ "Active" : {
+ "comment" : "Title for an insight card showing the number of active rituals.",
+ "isCommentAutoGenerated" : true
+ },
"Active rituals" : {
+ "extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -166,6 +200,10 @@
"comment" : "Subtitle for a feature in the \"Rituals Pro\" upgrade screen, related to \"Advanced Insights\".",
"isCommentAutoGenerated" : true
},
+ "All" : {
+ "comment" : "Title for the \"All\" option in the history ritual filter picker.",
+ "isCommentAutoGenerated" : true
+ },
"Balanced daily check-ins" : {
"comment" : "Description of what the \"Steady\" focus style means for the user.",
"isCommentAutoGenerated" : true
@@ -214,7 +252,12 @@
}
}
},
+ "Breakdown" : {
+ "comment" : "A label displayed above the breakdown of a user's insights.",
+ "isCommentAutoGenerated" : true
+ },
"Check-ins completed" : {
+ "extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -279,6 +322,9 @@
}
}
}
+ },
+ "Clear All Completions" : {
+
},
"Coming Soon" : {
"comment" : "A label indicating that a feature is coming soon.",
@@ -306,6 +352,10 @@
}
}
},
+ "Completed today" : {
+ "comment" : "Explanation of the Insight Card titled \"Habits today\", describing what the value represents.",
+ "isCommentAutoGenerated" : true
+ },
"Completion" : {
"localizations" : {
"en" : {
@@ -327,6 +377,9 @@
}
}
}
+ },
+ "Consecutive perfect days" : {
+
},
"Create as many arcs as you need" : {
"comment" : "Subtitle for a feature row in the \"Rituals Pro\" upgrade view, describing the ability to create as many rituals as needed.",
@@ -376,6 +429,10 @@
}
}
},
+ "Current streak" : {
+ "comment" : "Label for a breakdown item in the \"Streak\" insight card, indicating the current streak of consecutive days with 100% habit completion.",
+ "isCommentAutoGenerated" : true
+ },
"Custom Ritual" : {
"localizations" : {
"en" : {
@@ -420,6 +477,10 @@
}
}
},
+ "Day" : {
+ "comment" : "Label for the x-axis in the mini sparkline chart within an `InsightCardView`.",
+ "isCommentAutoGenerated" : true
+ },
"Day %lld of %lld" : {
"localizations" : {
"en" : {
@@ -464,6 +525,14 @@
}
}
},
+ "Days" : {
+ "comment" : "Title of an insight card showing the total number of days the user has been working on their rituals.",
+ "isCommentAutoGenerated" : true
+ },
+ "Days on your journey" : {
+ "comment" : "Title of an insight card that shows the total number of days a user has been working on their rituals.",
+ "isCommentAutoGenerated" : true
+ },
"Debug" : {
"localizations" : {
"en" : {
@@ -491,7 +560,6 @@
"isCommentAutoGenerated" : true
},
"Done" : {
- "extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -592,6 +660,7 @@
},
"Feel a soft response on check-in" : {
+ "extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -815,6 +884,10 @@
}
}
},
+ "habits completed" : {
+ "comment" : "A description of how many habits have been completed on a specific day.",
+ "isCommentAutoGenerated" : true
+ },
"Habits today" : {
"localizations" : {
"en" : {
@@ -861,6 +934,10 @@
},
"Help us build more features" : {
+ },
+ "History" : {
+ "comment" : "Title of the history view.",
+ "isCommentAutoGenerated" : true
},
"Hydrate" : {
"localizations" : {
@@ -928,6 +1005,10 @@
}
}
},
+ "In progress now" : {
+ "comment" : "Description of the \"Active\" insight card, indicating the number of active rituals.",
+ "isCommentAutoGenerated" : true
+ },
"Insights" : {
"localizations" : {
"en" : {
@@ -972,6 +1053,10 @@
}
}
},
+ "Last 7 days average" : {
+ "comment" : "Explanation of the \"Weekly trend\" insight card, describing how the user's average completion rate over the last 7 days is displayed.",
+ "isCommentAutoGenerated" : true
+ },
"Last synced %@" : {
"extractionState" : "stale",
"localizations" : {
@@ -995,6 +1080,10 @@
}
}
},
+ "Longest streak" : {
+ "comment" : "Label for the longest streak breakdown item.",
+ "isCommentAutoGenerated" : true
+ },
"Mindful minute" : {
"localizations" : {
"en" : {
@@ -1083,6 +1172,10 @@
}
}
},
+ "No habits tracked" : {
+ "comment" : "A message displayed when a user has not tracked any habits on a given day.",
+ "isCommentAutoGenerated" : true
+ },
"No ritual yet" : {
"localizations" : {
"en" : {
@@ -1218,6 +1311,9 @@
}
}
}
+ },
+ "Preload 6 Months Demo Data" : {
+
},
"Preview launch and icon" : {
"localizations" : {
@@ -1321,7 +1417,12 @@
}
}
},
+ "Ritual arcs you're currently working on. Each ritual is a focused journey lasting 2-8 weeks, helping you build lasting habits through consistency." : {
+ "comment" : "Explanation of the insight card titled \"Active\".",
+ "isCommentAutoGenerated" : true
+ },
"Ritual days" : {
+ "extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -1483,6 +1584,14 @@
}
}
},
+ "Show less" : {
+ "comment" : "A button label that indicates to collapse the history view.",
+ "isCommentAutoGenerated" : true
+ },
+ "Show more" : {
+ "comment" : "A button label that indicates more content is available.",
+ "isCommentAutoGenerated" : true
+ },
"Sign in to iCloud to enable sync" : {
"extractionState" : "stale",
"localizations" : {
@@ -1594,6 +1703,10 @@
}
}
},
+ "Streak" : {
+ "comment" : "Title of a section in the Insight Cards view, related to streaks of consecutive perfect days.",
+ "isCommentAutoGenerated" : true
+ },
"Stretch" : {
"localizations" : {
"en" : {
@@ -1806,6 +1919,10 @@
}
}
},
+ "Tap for details" : {
+ "comment" : "A hint that appears when a user taps on an element to learn more about it.",
+ "isCommentAutoGenerated" : true
+ },
"Tap to check in" : {
"localizations" : {
"en" : {
@@ -1828,6 +1945,22 @@
}
}
},
+ "Tap to view details" : {
+ "comment" : "A hint that appears when a user taps on a day cell to explain that it will navigate to more details about that day.",
+ "isCommentAutoGenerated" : true
+ },
+ "The number of habits you've checked off today across all your active rituals. Each check-in builds momentum toward your goals." : {
+ "comment" : "Explanation of the value for the Insight Card titled \"Habits today\".",
+ "isCommentAutoGenerated" : true
+ },
+ "The total number of days you've been working on your rituals. This shows your progress through each arc, combining all active rituals." : {
+ "comment" : "Explanation of the \"Days\" insight card in the Ritual Insights section.",
+ "isCommentAutoGenerated" : true
+ },
+ "This day has no habit data recorded." : {
+ "comment" : "A description displayed when a day has no habit completion data.",
+ "isCommentAutoGenerated" : true
+ },
"Time for your rituals" : {
"comment" : "Title of a notification displayed at the start of the day.",
"isCommentAutoGenerated" : true
@@ -1854,7 +1987,12 @@
}
}
},
+ "Today's progress" : {
+ "comment" : "Explanation of the \"Today's progress\" insight card in the Ritual Insights section.",
+ "isCommentAutoGenerated" : true
+ },
"Total arcs in motion" : {
+ "extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -1877,6 +2015,7 @@
}
},
"Total days logged" : {
+ "extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -1914,6 +2053,21 @@
"comment" : "Text for a settings card that allows users to upgrade to the Pro version of the app.",
"isCommentAutoGenerated" : true
},
+ "Vibrate when completing habits" : {
+
+ },
+ "Weekly completion chart" : {
+ "comment" : "An accessibility label for the weekly completion chart in the insight detail sheet.",
+ "isCommentAutoGenerated" : true
+ },
+ "Weekly trend" : {
+ "comment" : "Title of an insight card that shows the user their average completion rate over the last 7 days.",
+ "isCommentAutoGenerated" : true
+ },
+ "What this means" : {
+ "comment" : "A label displayed above the explanation text in the insight detail sheet.",
+ "isCommentAutoGenerated" : true
+ },
"Why arcs keep habits grounded" : {
"localizations" : {
"en" : {
@@ -1980,6 +2134,18 @@
}
}
},
+ "Your average completion rate over the last 7 days. This shows how consistent you've been recently and helps identify patterns." : {
+ "comment" : "Text for the \"Weekly trend\" insight card, describing its purpose and functionality.",
+ "isCommentAutoGenerated" : true
+ },
+ "Your completion percentage for today across all rituals. This resets each morning, giving you a fresh start every day." : {
+ "comment" : "Explanation of the insight card that shows the user their completion percentage for today across all their active rituals. This percentage resets each morning, providing a fresh start every day.",
+ "isCommentAutoGenerated" : true
+ },
+ "Your current streak of consecutive days with 100% habit completion. Complete all your habits today to keep the streak going!" : {
+ "comment" : "Explanation for the \"Streak\" insight card.",
+ "isCommentAutoGenerated" : true
+ },
"Your focus ritual lives here" : {
"localizations" : {
"en" : {
@@ -2002,6 +2168,10 @@
}
}
},
+ "Your journey over time" : {
+ "comment" : "Subtitle for the History view, describing what the view is about.",
+ "isCommentAutoGenerated" : true
+ },
"Your next chapter" : {
"localizations" : {
"en" : {
diff --git a/Andromida/App/Models/HabitCompletion.swift b/Andromida/App/Models/HabitCompletion.swift
new file mode 100644
index 0000000..c519d9d
--- /dev/null
+++ b/Andromida/App/Models/HabitCompletion.swift
@@ -0,0 +1,16 @@
+//
+// HabitCompletion.swift
+// Andromida
+//
+// Represents a habit's completion status for a specific day.
+//
+
+import Foundation
+
+/// Represents a habit's completion status with its ritual context.
+struct HabitCompletion: Identifiable {
+ var id: UUID { habit.id }
+ let habit: Habit
+ let ritualTitle: String
+ let isCompleted: Bool
+}
diff --git a/Andromida/App/Models/InsightCard.swift b/Andromida/App/Models/InsightCard.swift
index 7c62670..31f5d06 100644
--- a/Andromida/App/Models/InsightCard.swift
+++ b/Andromida/App/Models/InsightCard.swift
@@ -1,23 +1,48 @@
import Foundation
+/// A single insight metric displayed on the Insights tab.
struct InsightCard: Identifiable {
let id: UUID
let title: String
let value: String
- let caption: String
+ let caption: String // Short description shown on card
+ let explanation: String // Full explanation for detail sheet
let symbolName: String
+ let breakdown: [BreakdownItem]? // Optional per-ritual breakdown
+ let trendData: [TrendDataPoint]? // Optional trend data for sparkline charts
init(
id: UUID = UUID(),
title: String,
value: String,
caption: String,
- symbolName: String
+ explanation: String = "",
+ symbolName: String,
+ breakdown: [BreakdownItem]? = nil,
+ trendData: [TrendDataPoint]? = nil
) {
self.id = id
self.title = title
self.value = value
self.caption = caption
+ self.explanation = explanation
self.symbolName = symbolName
+ self.breakdown = breakdown
+ self.trendData = trendData
}
}
+
+/// A single item in the insight breakdown list.
+struct BreakdownItem: Identifiable {
+ let id = UUID()
+ let label: String
+ let value: String
+}
+
+/// A single data point for trend charts.
+struct TrendDataPoint: Identifiable {
+ let id = UUID()
+ let date: Date
+ let value: Double // 0.0 to 1.0 for percentage completion
+ let label: String // e.g., "Mon", "Tue"
+}
diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift
index fa3d057..bf673da 100644
--- a/Andromida/App/State/RitualStore.swift
+++ b/Andromida/App/State/RitualStore.swift
@@ -119,36 +119,212 @@ final class RitualStore: RitualStoreProviding {
)
}
+ // MARK: - Streak Tracking
+
+ /// Returns the set of all day IDs that had 100% completion across all rituals
+ private func perfectDays() -> Set {
+ guard !rituals.isEmpty else { return [] }
+
+ // Get all completed day IDs from all habits
+ var allDayIDs: Set = []
+ for ritual in rituals {
+ for habit in ritual.habits {
+ allDayIDs.formUnion(habit.completedDayIDs)
+ }
+ }
+
+ // Filter to days where ALL habits were completed
+ return allDayIDs.filter { dayID in
+ rituals.allSatisfy { ritual in
+ ritual.habits.allSatisfy { habit in
+ habit.completedDayIDs.contains(dayID)
+ }
+ }
+ }
+ }
+
+ /// Calculates the current streak (consecutive perfect days ending today or yesterday)
+ func currentStreak() -> Int {
+ let perfect = perfectDays()
+ guard !perfect.isEmpty else { return 0 }
+
+ var streak = 0
+ var checkDate = calendar.startOfDay(for: Date())
+
+ // Check if today is a perfect day
+ if perfect.contains(dayIdentifier(for: checkDate)) {
+ streak = 1
+ checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
+ }
+
+ // Count consecutive days backwards
+ while perfect.contains(dayIdentifier(for: checkDate)) {
+ streak += 1
+ checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
+ }
+
+ return streak
+ }
+
+ /// Calculates the longest streak of consecutive perfect days
+ func longestStreak() -> Int {
+ let perfect = perfectDays()
+ guard !perfect.isEmpty else { return 0 }
+
+ // Sort the perfect days chronologically
+ let sortedDates = perfect.compactMap { dayID -> Date? in
+ dayFormatter.date(from: dayID)
+ }.sorted()
+
+ guard !sortedDates.isEmpty else { return 0 }
+
+ var longest = 1
+ var current = 1
+
+ for i in 1.. [TrendDataPoint] {
+ let today = calendar.startOfDay(for: Date())
+ let shortWeekdayFormatter = DateFormatter()
+ shortWeekdayFormatter.calendar = calendar
+ shortWeekdayFormatter.dateFormat = "EEE"
+
+ var dataPoints: [TrendDataPoint] = []
+
+ for daysAgo in (0..<7).reversed() {
+ guard let date = calendar.date(byAdding: .day, value: -daysAgo, to: today) else { continue }
+ let dayID = dayIdentifier(for: date)
+
+ // Calculate completion rate for this day
+ let allHabits = rituals.flatMap { $0.habits }
+ guard !allHabits.isEmpty else {
+ dataPoints.append(TrendDataPoint(date: date, value: 0, label: shortWeekdayFormatter.string(from: date)))
+ continue
+ }
+
+ let completed = allHabits.filter { $0.completedDayIDs.contains(dayID) }.count
+ let rate = Double(completed) / Double(allHabits.count)
+
+ dataPoints.append(TrendDataPoint(
+ date: date,
+ value: rate,
+ label: shortWeekdayFormatter.string(from: date)
+ ))
+ }
+
+ return dataPoints
+ }
+
+ /// Returns the average completion rate for the last 7 days
+ func weeklyAverageCompletion() -> Int {
+ let trend = weeklyTrendData()
+ guard !trend.isEmpty else { return 0 }
+ let sum = trend.reduce(0.0) { $0 + $1.value }
+ return Int((sum / Double(trend.count)) * 100)
+ }
+
func insightCards() -> [InsightCard] {
let totalHabits = rituals.flatMap { $0.habits }.count
let completedToday = rituals.flatMap { $0.habits }.filter { isHabitCompletedToday($0) }.count
let completionRate = totalHabits == 0 ? 0 : Int((Double(completedToday) / Double(totalHabits)) * 100)
+
+ // Build per-ritual breakdowns
+ let ritualDaysBreakdown = rituals.map { ritual in
+ BreakdownItem(
+ label: ritual.title,
+ value: ritualDayLabel(for: ritual)
+ )
+ }
+
+ let habitsBreakdown = rituals.map { ritual in
+ let completed = ritual.habits.filter { isHabitCompletedToday($0) }.count
+ return BreakdownItem(
+ label: ritual.title,
+ value: "\(completed) of \(ritual.habits.count)"
+ )
+ }
+
let activeDays = rituals.map { ritualDayIndex(for: $0) }.reduce(0, +)
+
+ // Streak tracking
+ let current = currentStreak()
+ let longest = longestStreak()
+ let streakBreakdown = [
+ BreakdownItem(label: String(localized: "Current streak"), value: "\(current) days"),
+ BreakdownItem(label: String(localized: "Longest streak"), value: "\(longest) days")
+ ]
+
+ // Weekly trend
+ let trendData = weeklyTrendData()
+ let weeklyAverage = weeklyAverageCompletion()
+ let trendBreakdown = trendData.map { point in
+ BreakdownItem(label: point.label, value: "\(Int(point.value * 100))%")
+ }
return [
InsightCard(
- title: String(localized: "Active rituals"),
+ title: String(localized: "Active"),
value: "\(rituals.count)",
- caption: String(localized: "Total arcs in motion"),
- symbolName: "sparkles"
+ caption: String(localized: "In progress now"),
+ explanation: String(localized: "Ritual arcs you're currently working on. Each ritual is a focused journey lasting 2-8 weeks, helping you build lasting habits through consistency."),
+ symbolName: "sparkles",
+ breakdown: rituals.map { BreakdownItem(label: $0.title, value: $0.theme) }
+ ),
+ InsightCard(
+ title: String(localized: "Streak"),
+ value: "\(current)",
+ caption: String(localized: "Consecutive perfect days"),
+ explanation: String(localized: "Your current streak of consecutive days with 100% habit completion. Complete all your habits today to keep the streak going!"),
+ symbolName: "flame.fill",
+ breakdown: streakBreakdown
),
InsightCard(
title: String(localized: "Habits today"),
value: "\(completedToday)",
- caption: String(localized: "Check-ins completed"),
- symbolName: "checkmark.circle.fill"
+ caption: String(localized: "Completed today"),
+ explanation: String(localized: "The number of habits you've checked off today across all your active rituals. Each check-in builds momentum toward your goals."),
+ symbolName: "checkmark.circle.fill",
+ breakdown: habitsBreakdown
),
InsightCard(
title: String(localized: "Completion"),
value: "\(completionRate)%",
- caption: String(localized: "Across all rituals"),
- symbolName: "chart.bar.fill"
+ caption: String(localized: "Today's progress"),
+ explanation: String(localized: "Your completion percentage for today across all rituals. This resets each morning, giving you a fresh start every day."),
+ symbolName: "chart.bar.fill",
+ breakdown: nil
),
InsightCard(
- title: String(localized: "Ritual days"),
+ title: String(localized: "Days"),
value: "\(activeDays)",
- caption: String(localized: "Total days logged"),
- symbolName: "calendar"
+ caption: String(localized: "Days on your journey"),
+ explanation: String(localized: "The total number of days you've been working on your rituals. This shows your progress through each arc, combining all active rituals."),
+ symbolName: "calendar",
+ breakdown: ritualDaysBreakdown
+ ),
+ InsightCard(
+ title: String(localized: "Weekly trend"),
+ value: "\(weeklyAverage)%",
+ caption: String(localized: "Last 7 days average"),
+ explanation: String(localized: "Your average completion rate over the last 7 days. This shows how consistent you've been recently and helps identify patterns."),
+ symbolName: "chart.line.uptrend.xyaxis",
+ breakdown: trendBreakdown,
+ trendData: trendData
)
]
}
@@ -200,4 +376,132 @@ final class RitualStore: RitualStoreProviding {
private func dayIdentifier(for date: Date) -> String {
dayFormatter.string(from: date)
}
+
+ // MARK: - History / Calendar Support
+
+ /// Returns the completion rate for a specific date, optionally filtered by ritual.
+ /// - Parameters:
+ /// - date: The date to check
+ /// - ritual: If provided, only check habits from this ritual. If nil, check all habits.
+ /// - Returns: Completion rate from 0.0 to 1.0
+ func completionRate(for date: Date, ritual: Ritual? = nil) -> Double {
+ let dayID = dayIdentifier(for: date)
+ let habits: [Habit]
+
+ if let ritual = ritual {
+ habits = ritual.habits
+ } else {
+ habits = rituals.flatMap { $0.habits }
+ }
+
+ guard !habits.isEmpty else { return 0 }
+
+ let completed = habits.filter { $0.completedDayIDs.contains(dayID) }.count
+ return Double(completed) / Double(habits.count)
+ }
+
+ /// Returns all dates that have any habit activity.
+ func datesWithActivity() -> Set {
+ var dates: Set = []
+
+ for ritual in rituals {
+ for habit in ritual.habits {
+ for dayID in habit.completedDayIDs {
+ if let date = dayFormatter.date(from: dayID) {
+ dates.insert(calendar.startOfDay(for: date))
+ }
+ }
+ }
+ }
+
+ return dates
+ }
+
+ /// Returns the earliest date with any ritual activity.
+ func earliestActivityDate() -> Date? {
+ datesWithActivity().min()
+ }
+
+ /// Returns habit completion details for a specific date.
+ /// - Parameters:
+ /// - date: The date to check
+ /// - ritual: If provided, only return habits from this ritual. If nil, return all habits.
+ /// - Returns: Array of habit completions with ritual context
+ func habitCompletions(for date: Date, ritual: Ritual? = nil) -> [HabitCompletion] {
+ let dayID = dayIdentifier(for: date)
+ let targetRituals = ritual.map { [$0] } ?? rituals
+
+ var completions: [HabitCompletion] = []
+
+ for r in targetRituals {
+ for habit in r.habits {
+ completions.append(HabitCompletion(
+ habit: habit,
+ ritualTitle: r.title,
+ isCompleted: habit.completedDayIDs.contains(dayID)
+ ))
+ }
+ }
+
+ return completions
+ }
+
+ /// Checks if a habit was completed on a specific date.
+ func isHabitCompleted(_ habit: Habit, on date: Date) -> Bool {
+ let dayID = dayIdentifier(for: date)
+ return habit.completedDayIDs.contains(dayID)
+ }
+
+ // MARK: - Debug / Demo Data
+
+ #if DEBUG
+ /// Preloads 6 months of random completion data for testing the history view.
+ /// Each day has a random chance (60-90%) of completing each habit.
+ func preloadDemoData() {
+ let today = calendar.startOfDay(for: Date())
+
+ // Go back 6 months
+ guard let sixMonthsAgo = calendar.date(byAdding: .month, value: -6, to: today) else { return }
+
+ // Update each ritual's start date to be 6 months ago
+ for ritual in rituals {
+ ritual.startDate = sixMonthsAgo
+ ritual.durationDays = 180 + 28 // Cover 6 months plus buffer
+ }
+
+ // Generate completions for each day from 6 months ago to yesterday
+ var currentDate = sixMonthsAgo
+
+ while currentDate < today {
+ let dayID = dayIdentifier(for: currentDate)
+
+ for ritual in rituals {
+ for habit in ritual.habits {
+ // Random completion with ~70% average success rate
+ // Vary between 50-90% to create realistic patterns
+ let threshold = Double.random(in: 0.5...0.9)
+ let shouldComplete = Double.random(in: 0...1) < threshold
+
+ if shouldComplete && !habit.completedDayIDs.contains(dayID) {
+ habit.completedDayIDs.append(dayID)
+ }
+ }
+ }
+
+ currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate
+ }
+
+ saveContext()
+ }
+
+ /// Clears all completion data (for testing).
+ func clearAllCompletions() {
+ for ritual in rituals {
+ for habit in ritual.habits {
+ habit.completedDayIDs.removeAll()
+ }
+ }
+ saveContext()
+ }
+ #endif
}
diff --git a/Andromida/App/Views/History/Components/HistoryDayCell.swift b/Andromida/App/Views/History/Components/HistoryDayCell.swift
new file mode 100644
index 0000000..0a22995
--- /dev/null
+++ b/Andromida/App/Views/History/Components/HistoryDayCell.swift
@@ -0,0 +1,85 @@
+//
+// HistoryDayCell.swift
+// Andromida
+//
+// A single day cell in the history calendar showing completion progress.
+//
+
+import SwiftUI
+import Bedrock
+
+/// A single day cell showing the day number and a progress ring.
+struct HistoryDayCell: View {
+ let date: Date
+ let progress: Double
+ let isToday: Bool
+ var isFuture: Bool = false
+ let onTap: () -> Void
+
+ private let calendar = Calendar.current
+
+ private var dayNumber: Int {
+ calendar.component(.day, from: date)
+ }
+
+ private var textColor: Color {
+ if isFuture {
+ return AppTextColors.tertiary
+ } else if isToday {
+ return AppAccent.primary
+ } else {
+ return AppTextColors.primary
+ }
+ }
+
+ var body: some View {
+ Button(action: onTap) {
+ ZStack {
+ // Progress ring (or empty ring for future days)
+ ProgressRing(progress: progress, size: AppMetrics.Size.historyDayCell)
+
+ // Day number
+ Text("\(dayNumber)")
+ .font(.caption2)
+ .fontWeight(isToday ? .bold : .regular)
+ .foregroundStyle(textColor)
+ }
+ .frame(width: AppMetrics.Size.historyDayCell, height: AppMetrics.Size.historyDayCell)
+ }
+ .buttonStyle(.plain)
+ .disabled(isFuture)
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(accessibilityLabel)
+ .accessibilityHint(isFuture ? "" : String(localized: "Tap to view details"))
+ }
+
+ private var accessibilityLabel: String {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .medium
+ let dateString = formatter.string(from: date)
+ if isFuture {
+ return "\(dateString), future date"
+ }
+ let percentComplete = Int(progress * 100)
+ return "\(dateString), \(percentComplete) percent complete"
+ }
+}
+
+/// An empty placeholder cell for days outside the current month.
+struct HistoryDayPlaceholder: View {
+ var body: some View {
+ Color.clear
+ .frame(width: AppMetrics.Size.historyDayCell, height: AppMetrics.Size.historyDayCell)
+ }
+}
+
+#Preview {
+ HStack(spacing: Design.Spacing.small) {
+ HistoryDayCell(date: Date(), progress: 1.0, isToday: true) {}
+ HistoryDayCell(date: Date(), progress: 0.66, isToday: false) {}
+ HistoryDayCell(date: Date(), progress: 0.33, isToday: false) {}
+ HistoryDayCell(date: Date(), progress: 0.0, isToday: false) {}
+ }
+ .padding()
+ .background(AppSurface.primary)
+}
diff --git a/Andromida/App/Views/History/Components/ProgressRing.swift b/Andromida/App/Views/History/Components/ProgressRing.swift
new file mode 100644
index 0000000..494f1df
--- /dev/null
+++ b/Andromida/App/Views/History/Components/ProgressRing.swift
@@ -0,0 +1,58 @@
+//
+// ProgressRing.swift
+// Andromida
+//
+// A circular progress indicator for the history calendar.
+//
+
+import SwiftUI
+import Bedrock
+
+/// A circular progress ring showing completion percentage.
+struct ProgressRing: View {
+ let progress: Double // 0.0 to 1.0
+ let size: CGFloat
+ var lineWidth: CGFloat = 3
+
+ private var color: Color {
+ if progress >= 1.0 { return AppStatus.success }
+ if progress >= 0.5 { return AppAccent.primary }
+ return AppTextColors.tertiary
+ }
+
+ var body: some View {
+ ZStack {
+ // Background ring
+ Circle()
+ .stroke(
+ AppBorder.subtle,
+ lineWidth: lineWidth
+ )
+
+ // Progress ring
+ Circle()
+ .trim(from: 0, to: min(progress, 1.0))
+ .stroke(
+ color,
+ style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
+ )
+ .rotationEffect(.degrees(-90))
+ .animation(.easeOut(duration: Design.Animation.standard), value: progress)
+ }
+ .frame(width: size, height: size)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(String(localized: "\(Int(progress * 100)) percent complete"))
+ }
+}
+
+#Preview {
+ HStack(spacing: Design.Spacing.large) {
+ ProgressRing(progress: 0.0, size: 40)
+ ProgressRing(progress: 0.25, size: 40)
+ ProgressRing(progress: 0.5, size: 40)
+ ProgressRing(progress: 0.75, size: 40)
+ ProgressRing(progress: 1.0, size: 40)
+ }
+ .padding()
+ .background(AppSurface.primary)
+}
diff --git a/Andromida/App/Views/History/HistoryDayDetailSheet.swift b/Andromida/App/Views/History/HistoryDayDetailSheet.swift
new file mode 100644
index 0000000..b3a93b2
--- /dev/null
+++ b/Andromida/App/Views/History/HistoryDayDetailSheet.swift
@@ -0,0 +1,165 @@
+//
+// HistoryDayDetailSheet.swift
+// Andromida
+//
+// Detail sheet showing habit completions for a specific day.
+//
+
+import SwiftUI
+import Bedrock
+
+/// A sheet showing habit completion details for a specific day.
+struct HistoryDayDetailSheet: View {
+ let date: Date
+ let completions: [HabitCompletion]
+ @Environment(\.dismiss) private var dismiss
+
+ private var dateTitle: String {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .full
+ return formatter.string(from: date)
+ }
+
+ private var completionRate: Double {
+ guard !completions.isEmpty else { return 0 }
+ let completed = completions.filter { $0.isCompleted }.count
+ return Double(completed) / Double(completions.count)
+ }
+
+ private var completedCount: Int {
+ completions.filter { $0.isCompleted }.count
+ }
+
+ var body: some View {
+ NavigationStack {
+ ScrollView(.vertical, showsIndicators: false) {
+ VStack(alignment: .leading, spacing: Design.Spacing.large) {
+ // Summary header
+ summaryHeader
+
+ // Habit list grouped by ritual
+ if completions.isEmpty {
+ emptyState
+ } else {
+ habitList
+ }
+
+ Spacer(minLength: Design.Spacing.xxxLarge)
+ }
+ .padding(Design.Spacing.large)
+ }
+ .scrollContentBackground(.hidden)
+ .background(AppSurface.primary)
+ .navigationTitle(shortDateTitle)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button(String(localized: "Done")) {
+ dismiss()
+ }
+ .foregroundStyle(AppAccent.primary)
+ }
+ }
+ }
+ .presentationBackground(AppSurface.primary)
+ .presentationDetents([.medium, .large])
+ .presentationDragIndicator(.visible)
+ }
+
+ private var emptyState: some View {
+ VStack(spacing: Design.Spacing.medium) {
+ Image(systemName: "calendar.badge.clock")
+ .font(.largeTitle)
+ .foregroundStyle(AppTextColors.tertiary)
+
+ Text(String(localized: "No habits tracked"))
+ .font(.headline)
+ .foregroundStyle(AppTextColors.secondary)
+
+ Text(String(localized: "This day has no habit data recorded."))
+ .font(.subheadline)
+ .foregroundStyle(AppTextColors.tertiary)
+ .multilineTextAlignment(.center)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, Design.Spacing.xxxLarge)
+ }
+
+ private var shortDateTitle: String {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .medium
+ return formatter.string(from: date)
+ }
+
+ private var summaryHeader: some View {
+ VStack(spacing: Design.Spacing.medium) {
+ ProgressRing(progress: completionRate, size: 80, lineWidth: 6)
+
+ Text("\(completedCount) of \(completions.count)")
+ .font(.title2)
+ .bold()
+ .foregroundStyle(AppTextColors.primary)
+
+ Text(String(localized: "habits completed"))
+ .font(.subheadline)
+ .foregroundStyle(AppTextColors.secondary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, Design.Spacing.large)
+ }
+
+ private var habitList: some View {
+ let groupedByRitual = Dictionary(grouping: completions) { $0.ritualTitle }
+ let sortedRituals = groupedByRitual.keys.sorted()
+
+ return VStack(alignment: .leading, spacing: Design.Spacing.large) {
+ ForEach(sortedRituals, id: \.self) { ritualTitle in
+ if let habits = groupedByRitual[ritualTitle] {
+ ritualSection(title: ritualTitle, habits: habits)
+ }
+ }
+ }
+ }
+
+ private func ritualSection(title: String, habits: [HabitCompletion]) -> some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ Text(title)
+ .font(.headline)
+ .foregroundStyle(AppTextColors.primary)
+
+ VStack(spacing: Design.Spacing.xSmall) {
+ ForEach(habits) { completion in
+ habitRow(completion)
+ }
+ }
+ }
+ .padding(Design.Spacing.large)
+ .background(AppSurface.card)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
+ }
+
+ private func habitRow(_ completion: HabitCompletion) -> some View {
+ HStack(spacing: Design.Spacing.medium) {
+ Image(systemName: completion.habit.symbolName)
+ .foregroundStyle(completion.isCompleted ? AppStatus.success : AppTextColors.tertiary)
+ .frame(width: AppMetrics.Size.iconMedium)
+
+ Text(completion.habit.title)
+ .font(.subheadline)
+ .foregroundStyle(AppTextColors.primary)
+
+ Spacer()
+
+ Image(systemName: completion.isCompleted ? "checkmark.circle.fill" : "circle")
+ .foregroundStyle(completion.isCompleted ? AppStatus.success : AppTextColors.tertiary)
+ }
+ .padding(.vertical, Design.Spacing.small)
+ }
+}
+
+#Preview {
+ HistoryDayDetailSheet(
+ date: Date(),
+ completions: []
+ )
+}
diff --git a/Andromida/App/Views/History/HistoryMonthView.swift b/Andromida/App/Views/History/HistoryMonthView.swift
new file mode 100644
index 0000000..b888981
--- /dev/null
+++ b/Andromida/App/Views/History/HistoryMonthView.swift
@@ -0,0 +1,125 @@
+//
+// HistoryMonthView.swift
+// Andromida
+//
+// A single month calendar grid showing daily completion progress.
+//
+
+import SwiftUI
+import Bedrock
+
+/// A calendar grid for a single month with progress rings for each day.
+struct HistoryMonthView: View {
+ let month: Date
+ let selectedRitual: Ritual?
+ let completionRate: (Date, Ritual?) -> Double
+ let onDayTapped: (Date) -> Void
+
+ private let calendar = Calendar.current
+ private let weekdaySymbols = Calendar.current.shortWeekdaySymbols
+
+ private var monthTitle: String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "MMMM yyyy"
+ return formatter.string(from: month)
+ }
+
+ private var daysInMonth: [Date?] {
+ // Get the first day of the month
+ guard let firstOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: month)) else {
+ return []
+ }
+
+ // Get the range of days in this month
+ guard let range = calendar.range(of: .day, in: .month, for: firstOfMonth) else {
+ return []
+ }
+
+ // Get the weekday of the first day (1 = Sunday, 7 = Saturday)
+ let firstWeekday = calendar.component(.weekday, from: firstOfMonth)
+
+ // Create array with leading nil placeholders for alignment
+ var days: [Date?] = Array(repeating: nil, count: firstWeekday - 1)
+
+ // Add actual days
+ for day in range {
+ if let date = calendar.date(byAdding: .day, value: day - 1, to: firstOfMonth) {
+ days.append(date)
+ }
+ }
+
+ return days
+ }
+
+ private var today: Date {
+ calendar.startOfDay(for: Date())
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.medium) {
+ // Month header
+ Text(monthTitle)
+ .font(.headline)
+ .foregroundStyle(AppTextColors.primary)
+ .padding(.horizontal, Design.Spacing.small)
+
+ // Weekday headers
+ weekdayHeader
+
+ // Day grid
+ dayGrid
+ }
+ .padding(Design.Spacing.medium)
+ .background(AppSurface.card)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
+ }
+
+ private var weekdayHeader: some View {
+ HStack(spacing: 0) {
+ ForEach(weekdaySymbols, id: \.self) { symbol in
+ Text(symbol)
+ .font(.caption2)
+ .foregroundStyle(AppTextColors.tertiary)
+ .frame(maxWidth: .infinity)
+ }
+ }
+ }
+
+ private var dayGrid: some View {
+ let columns = Array(repeating: GridItem(.flexible(), spacing: Design.Spacing.xSmall), count: 7)
+
+ return LazyVGrid(columns: columns, spacing: Design.Spacing.xSmall) {
+ ForEach(Array(daysInMonth.enumerated()), id: \.offset) { index, date in
+ if let date = date {
+ let isToday = calendar.isDate(date, inSameDayAs: today)
+ let isFuture = date > today
+
+ // Show all days - future days show with 0 progress and are not tappable
+ HistoryDayCell(
+ date: date,
+ progress: isFuture ? 0 : completionRate(date, selectedRitual),
+ isToday: isToday,
+ isFuture: isFuture,
+ onTap: { if !isFuture { onDayTapped(date) } }
+ )
+ } else {
+ // Placeholder for alignment
+ HistoryDayPlaceholder()
+ }
+ }
+ }
+ }
+}
+
+#Preview {
+ ScrollView {
+ HistoryMonthView(
+ month: Date(),
+ selectedRitual: nil,
+ completionRate: { _, _ in Double.random(in: 0...1) },
+ onDayTapped: { _ in }
+ )
+ .padding()
+ }
+ .background(AppSurface.primary)
+}
diff --git a/Andromida/App/Views/History/HistoryView.swift b/Andromida/App/Views/History/HistoryView.swift
new file mode 100644
index 0000000..23952d0
--- /dev/null
+++ b/Andromida/App/Views/History/HistoryView.swift
@@ -0,0 +1,166 @@
+//
+// HistoryView.swift
+// Andromida
+//
+// A scrollable calendar history view showing daily habit completion.
+//
+
+import SwiftUI
+import Bedrock
+
+/// Main history view with scrollable months and ritual filtering.
+/// Wrapper struct to make Date identifiable for sheet presentation
+struct IdentifiableDate: Identifiable {
+ let id = UUID()
+ let date: Date
+}
+
+struct HistoryView: View {
+ @Bindable var store: RitualStore
+ @State private var selectedRitual: Ritual?
+ @State private var selectedDateItem: IdentifiableDate?
+ @State private var showingExpandedHistory = false
+
+ private let calendar = Calendar.current
+
+ /// Generate months based on expanded state
+ /// - Collapsed: Last month + current month (2 months)
+ /// - Expanded: Up to 12 months of history
+ /// Months are ordered oldest first, newest last (chronological order)
+ private var months: [Date] {
+ let today = Date()
+ let currentMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: today)) ?? today
+
+ // Determine how far back to go
+ let monthsBack = showingExpandedHistory ? 11 : 1 // 12 months or 2 months total
+ guard let startMonth = calendar.date(byAdding: .month, value: -monthsBack, to: currentMonth) else {
+ return [currentMonth]
+ }
+
+ // Build list of months in chronological order (oldest first)
+ var result: [Date] = []
+ var current = startMonth
+
+ while current <= currentMonth {
+ result.append(current)
+ current = calendar.date(byAdding: .month, value: 1, to: current) ?? current
+ }
+
+ return result
+ }
+
+ /// Check if there's more history available beyond what's shown
+ private var hasMoreHistory: Bool {
+ guard let earliestActivity = store.earliestActivityDate() else { return false }
+ let today = Date()
+ guard let twoMonthsAgo = calendar.date(byAdding: .month, value: -1, to: today) else { return false }
+ return earliestActivity < twoMonthsAgo
+ }
+
+ var body: some View {
+ ScrollView(.vertical, showsIndicators: false) {
+ VStack(alignment: .leading, spacing: Design.Spacing.large) {
+ // Header with Show More/Less button
+ headerSection
+
+ // Ritual filter picker
+ ritualPicker
+
+ // Month calendars
+ ForEach(months, id: \.self) { month in
+ HistoryMonthView(
+ month: month,
+ selectedRitual: selectedRitual,
+ completionRate: { date, ritual in
+ store.completionRate(for: date, ritual: ritual)
+ },
+ onDayTapped: { date in
+ selectedDateItem = IdentifiableDate(date: date)
+ }
+ )
+ }
+ }
+ .padding(Design.Spacing.large)
+ }
+ .background(LinearGradient(
+ colors: [AppSurface.primary, AppSurface.secondary],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ ))
+ .sheet(item: $selectedDateItem) { item in
+ HistoryDayDetailSheet(
+ date: item.date,
+ completions: store.habitCompletions(for: item.date, ritual: selectedRitual)
+ )
+ }
+ }
+
+ private var headerSection: some View {
+ HStack(alignment: .top) {
+ SectionHeaderView(
+ title: String(localized: "History"),
+ subtitle: String(localized: "Your journey over time")
+ )
+
+ Spacer()
+
+ if hasMoreHistory || showingExpandedHistory {
+ Button {
+ withAnimation(.easeInOut(duration: Design.Animation.standard)) {
+ showingExpandedHistory.toggle()
+ }
+ } label: {
+ HStack(spacing: Design.Spacing.xSmall) {
+ Text(showingExpandedHistory ? String(localized: "Show less") : String(localized: "Show more"))
+ .font(.subheadline)
+ Image(systemName: showingExpandedHistory ? "chevron.up" : "chevron.down")
+ .font(.caption)
+ }
+ .foregroundStyle(AppAccent.primary)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+
+ private var ritualPicker: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: Design.Spacing.small) {
+ // "All" option
+ filterChip(
+ title: String(localized: "All"),
+ isSelected: selectedRitual == nil,
+ action: { selectedRitual = nil }
+ )
+
+ // Individual rituals
+ ForEach(store.rituals) { ritual in
+ filterChip(
+ title: ritual.title,
+ isSelected: selectedRitual?.id == ritual.id,
+ action: { selectedRitual = ritual }
+ )
+ }
+ }
+ }
+ }
+
+ private func filterChip(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ Text(title)
+ .font(.subheadline)
+ .fontWeight(isSelected ? .semibold : .regular)
+ .foregroundStyle(isSelected ? AppTextColors.inverse : AppTextColors.primary)
+ .padding(.horizontal, Design.Spacing.medium)
+ .padding(.vertical, Design.Spacing.small)
+ .background(isSelected ? AppAccent.primary : AppSurface.card)
+ .clipShape(.capsule)
+ }
+ .buttonStyle(.plain)
+ .accessibilityAddTraits(isSelected ? .isSelected : [])
+ }
+}
+
+#Preview {
+ HistoryView(store: RitualStore.preview)
+}
diff --git a/Andromida/App/Views/Insights/Components/InsightCardView.swift b/Andromida/App/Views/Insights/Components/InsightCardView.swift
index 76983d6..b611b5b 100644
--- a/Andromida/App/Views/Insights/Components/InsightCardView.swift
+++ b/Andromida/App/Views/Insights/Components/InsightCardView.swift
@@ -1,51 +1,102 @@
import SwiftUI
import Bedrock
+import Charts
struct InsightCardView: View {
- private let title: String
- private let value: String
- private let caption: String
- private let symbolName: String
-
- init(
- title: String,
- value: String,
- caption: String,
- symbolName: String
- ) {
- self.title = title
- self.value = value
- self.caption = caption
- self.symbolName = symbolName
- }
+ let card: InsightCard
+ @State private var showingDetail = false
var body: some View {
+ Button {
+ showingDetail = true
+ } label: {
+ cardContent
+ }
+ .buttonStyle(.plain)
+ .sheet(isPresented: $showingDetail) {
+ InsightDetailSheet(card: card)
+ }
+ }
+
+ private var cardContent: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
HStack(spacing: Design.Spacing.small) {
- Image(systemName: symbolName)
+ Image(systemName: card.symbolName)
.foregroundStyle(AppAccent.primary)
.accessibilityHidden(true)
- Text(title)
+ Text(card.title)
.font(.subheadline)
.foregroundStyle(AppTextColors.secondary)
+
+ Spacer()
+
+ // Subtle tap affordance
+ Image(systemName: "chevron.right")
+ .font(.caption2)
+ .foregroundStyle(AppTextColors.tertiary)
+ .accessibilityHidden(true)
+ }
+
+ // Show mini sparkline if trend data is available
+ if let trendData = card.trendData, !trendData.isEmpty {
+ HStack(alignment: .bottom, spacing: Design.Spacing.medium) {
+ VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
+ Text(card.value)
+ .font(.title)
+ .foregroundStyle(AppTextColors.primary)
+ .bold()
+ Text(card.caption)
+ .font(.caption)
+ .foregroundStyle(AppTextColors.secondary)
+ }
+
+ Spacer()
+
+ // Mini sparkline chart
+ Chart(trendData) { point in
+ BarMark(
+ x: .value("Day", point.label),
+ y: .value("Completion", point.value)
+ )
+ .foregroundStyle(
+ point.value >= 1.0 ? AppStatus.success :
+ point.value >= 0.5 ? AppAccent.primary :
+ AppTextColors.tertiary
+ )
+ .cornerRadius(2)
+ }
+ .chartYScale(domain: 0...1)
+ .chartXAxis(.hidden)
+ .chartYAxis(.hidden)
+ .frame(width: 80, height: 40)
+ .accessibilityHidden(true)
+ }
+ } else {
+ Text(card.value)
+ .font(.title)
+ .foregroundStyle(AppTextColors.primary)
+ .bold()
+ Text(card.caption)
+ .font(.caption)
+ .foregroundStyle(AppTextColors.secondary)
}
- Text(value)
- .font(.title)
- .foregroundStyle(AppTextColors.primary)
- .bold()
- Text(caption)
- .font(.caption)
- .foregroundStyle(AppTextColors.secondary)
}
.padding(Design.Spacing.large)
.background(AppSurface.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.accessibilityElement(children: .combine)
+ .accessibilityHint(String(localized: "Tap for details"))
}
}
#Preview {
- InsightCardView(title: "Completion", value: "72%", caption: "Across all rituals", symbolName: "chart.bar.fill")
- .padding(Design.Spacing.large)
+ InsightCardView(card: InsightCard(
+ title: "Completion",
+ value: "72%",
+ caption: "Today's progress",
+ explanation: "Your completion percentage for today across all rituals.",
+ symbolName: "chart.bar.fill"
+ ))
+ .padding(Design.Spacing.large)
.background(AppSurface.primary)
}
diff --git a/Andromida/App/Views/Insights/Components/InsightDetailSheet.swift b/Andromida/App/Views/Insights/Components/InsightDetailSheet.swift
new file mode 100644
index 0000000..597a620
--- /dev/null
+++ b/Andromida/App/Views/Insights/Components/InsightDetailSheet.swift
@@ -0,0 +1,196 @@
+//
+// InsightDetailSheet.swift
+// Andromida
+//
+// Detail sheet shown when tapping an insight card.
+//
+
+import SwiftUI
+import Bedrock
+import Charts
+
+struct InsightDetailSheet: View {
+ let card: InsightCard
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationStack {
+ ScrollView(.vertical, showsIndicators: false) {
+ VStack(alignment: .leading, spacing: Design.Spacing.xLarge) {
+ // Header with icon and value
+ headerSection
+
+ // Chart (if trend data available)
+ if let trendData = card.trendData, !trendData.isEmpty {
+ chartSection(trendData)
+ }
+
+ // Explanation
+ explanationSection
+
+ // Breakdown (if available)
+ if let breakdown = card.breakdown, !breakdown.isEmpty {
+ breakdownSection(breakdown)
+ }
+
+ Spacer(minLength: Design.Spacing.xxxLarge)
+ }
+ .padding(Design.Spacing.large)
+ }
+ .background(AppSurface.primary)
+ .navigationTitle(card.title)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button(String(localized: "Done")) {
+ dismiss()
+ }
+ .foregroundStyle(AppAccent.primary)
+ }
+ }
+ }
+ .presentationDetents([.medium, .large])
+ .presentationDragIndicator(.visible)
+ }
+
+ // MARK: - Header Section
+
+ private var headerSection: some View {
+ VStack(spacing: Design.Spacing.medium) {
+ Image(systemName: card.symbolName)
+ .font(.system(size: Design.BaseFontSize.largeTitle * 2))
+ .foregroundStyle(AppAccent.primary)
+ .accessibilityHidden(true)
+
+ Text(card.value)
+ .font(.system(size: Design.BaseFontSize.largeTitle * 2, weight: .bold))
+ .foregroundStyle(AppTextColors.primary)
+
+ Text(card.caption)
+ .font(.subheadline)
+ .foregroundStyle(AppTextColors.secondary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, Design.Spacing.large)
+ }
+
+ // MARK: - Explanation Section
+
+ private var explanationSection: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ Text(String(localized: "What this means"))
+ .font(.headline)
+ .foregroundStyle(AppTextColors.primary)
+
+ Text(card.explanation)
+ .font(.body)
+ .foregroundStyle(AppTextColors.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .padding(Design.Spacing.large)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(AppSurface.card)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
+ }
+
+ // MARK: - Chart Section
+
+ private func chartSection(_ data: [TrendDataPoint]) -> some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ Text(String(localized: "7-Day Trend"))
+ .font(.headline)
+ .foregroundStyle(AppTextColors.primary)
+
+ Chart(data) { point in
+ BarMark(
+ x: .value("Day", point.label),
+ y: .value("Completion", point.value)
+ )
+ .foregroundStyle(
+ point.value >= 1.0 ? AppStatus.success.gradient :
+ point.value >= 0.5 ? AppAccent.primary.gradient :
+ AppTextColors.tertiary.gradient
+ )
+ .cornerRadius(Design.CornerRadius.small)
+ }
+ .chartYScale(domain: 0...1)
+ .chartYAxis {
+ AxisMarks(values: [0, 0.5, 1.0]) { value in
+ AxisValueLabel {
+ if let v = value.as(Double.self) {
+ Text("\(Int(v * 100))%")
+ .font(.caption2)
+ .foregroundStyle(AppTextColors.tertiary)
+ }
+ }
+ AxisGridLine()
+ .foregroundStyle(AppBorder.subtle)
+ }
+ }
+ .chartXAxis {
+ AxisMarks { value in
+ AxisValueLabel()
+ .font(.caption2)
+ .foregroundStyle(AppTextColors.secondary)
+ }
+ }
+ .frame(height: 180)
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(String(localized: "Weekly completion chart"))
+ }
+ .padding(Design.Spacing.large)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(AppSurface.card)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
+ }
+
+ // MARK: - Breakdown Section
+
+ private func breakdownSection(_ breakdown: [BreakdownItem]) -> some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ Text(String(localized: "Breakdown"))
+ .font(.headline)
+ .foregroundStyle(AppTextColors.primary)
+
+ VStack(spacing: Design.Spacing.xSmall) {
+ ForEach(breakdown) { item in
+ HStack {
+ Text(item.label)
+ .font(.subheadline)
+ .foregroundStyle(AppTextColors.primary)
+
+ Spacer()
+
+ Text(item.value)
+ .font(.subheadline)
+ .foregroundStyle(AppTextColors.secondary)
+ }
+ .padding(.vertical, Design.Spacing.small)
+
+ if item.id != breakdown.last?.id {
+ Divider()
+ .background(AppBorder.subtle)
+ }
+ }
+ }
+ }
+ .padding(Design.Spacing.large)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(AppSurface.card)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
+ }
+}
+
+#Preview {
+ InsightDetailSheet(card: InsightCard(
+ title: "Ritual days",
+ value: "16",
+ caption: "Days on your journey",
+ explanation: "The total number of days you've been working on your rituals. This shows your progress through each arc, combining all active rituals.",
+ symbolName: "calendar",
+ breakdown: [
+ BreakdownItem(label: "Morning Clarity", value: "Day 1 of 28"),
+ BreakdownItem(label: "Evening Reset", value: "Day 15 of 28")
+ ]
+ ))
+}
diff --git a/Andromida/App/Views/Insights/InsightsView.swift b/Andromida/App/Views/Insights/InsightsView.swift
index 375e6ed..e4cc8bd 100644
--- a/Andromida/App/Views/Insights/InsightsView.swift
+++ b/Andromida/App/Views/Insights/InsightsView.swift
@@ -18,12 +18,7 @@ struct InsightsView: View {
LazyVGrid(columns: columns, spacing: Design.Spacing.medium) {
ForEach(store.insightCards()) { card in
- InsightCardView(
- title: card.title,
- value: card.value,
- caption: card.caption,
- symbolName: card.symbolName
- )
+ InsightCardView(card: card)
}
}
}
diff --git a/Andromida/App/Views/RootView.swift b/Andromida/App/Views/RootView.swift
index cc28454..a7d26c3 100644
--- a/Andromida/App/Views/RootView.swift
+++ b/Andromida/App/Views/RootView.swift
@@ -27,9 +27,15 @@ struct RootView: View {
}
}
+ Tab(String(localized: "History"), systemImage: "calendar") {
+ NavigationStack {
+ HistoryView(store: store)
+ }
+ }
+
Tab(String(localized: "Settings"), systemImage: "gearshape.fill") {
NavigationStack {
- SettingsView(store: settingsStore)
+ SettingsView(store: settingsStore, ritualStore: store)
}
}
}
diff --git a/Andromida/App/Views/Settings/SettingsView.swift b/Andromida/App/Views/Settings/SettingsView.swift
index fdfb7f0..7c5c749 100644
--- a/Andromida/App/Views/Settings/SettingsView.swift
+++ b/Andromida/App/Views/Settings/SettingsView.swift
@@ -4,6 +4,7 @@ import UserNotifications
struct SettingsView: View {
@Bindable var store: SettingsStore
+ var ritualStore: RitualStore?
private let focusOptions: [(String, FocusStyle)] = FocusStyle.allCases.map { ($0.title, $0) }
@@ -165,6 +166,24 @@ struct SettingsView: View {
) {
UserDefaults.standard.removeObject(forKey: "hasCompletedOnboarding")
}
+
+ if let ritualStore {
+ SettingsRow(
+ systemImage: "calendar.badge.plus",
+ title: String(localized: "Preload 6 Months Demo Data"),
+ iconColor: AppStatus.info
+ ) {
+ ritualStore.preloadDemoData()
+ }
+
+ SettingsRow(
+ systemImage: "trash",
+ title: String(localized: "Clear All Completions"),
+ iconColor: AppStatus.error
+ ) {
+ ritualStore.clearAllCompletions()
+ }
+ }
}
#endif
@@ -197,6 +216,6 @@ extension SettingsView {
#Preview {
NavigationStack {
- SettingsView(store: SettingsStore.preview)
+ SettingsView(store: SettingsStore.preview, ritualStore: nil)
}
}
diff --git a/Andromida/Shared/AppMetrics.swift b/Andromida/Shared/AppMetrics.swift
index a726d4f..1df6eda 100644
--- a/Andromida/Shared/AppMetrics.swift
+++ b/Andromida/Shared/AppMetrics.swift
@@ -8,6 +8,7 @@ enum AppMetrics {
static let progressRing: CGFloat = 72
static let buttonHeight: CGFloat = 46
static let insightCardMinWidth: CGFloat = 160
+ static let historyDayCell: CGFloat = 40
}
enum Shadow {
diff --git a/TODO.md b/TODO.md
index cfa91aa..c01ec71 100644
--- a/TODO.md
+++ b/TODO.md
@@ -15,71 +15,38 @@
- [x] Re-add accessibility value/hint for habit rows once Sherpa-related ambiguity is resolved.
- [x] Confirm focus ritual card and habit rows still match the intended visual hierarchy after refactors.
-## 4) QA checklist
+## 4) Settings & product readiness
+- [x] Add a paid-app placeholder (e.g., "Pro unlock" copy) without backend requirements.
+- [x] Confirm default settings and theme in Settings match Bedrock branding.
+- [x] Wire up haptics setting to habit check-in feedback.
+- [x] Wire up sound setting to habit check-in feedback.
+- [x] Wire up ritual length setting to quick ritual creation.
+- [x] Add daily reminder notification scheduling with time picker.
+
+## 5) Data & defaults
+- [x] Confirm seed ritual creation and quick ritual creation behave as expected.
+- [x] Validate SwiftData sync (if enabled) doesn't require any external API.
+
+## 6) QA checklist
- [x] First-launch walkthrough appears on a clean install.
- [x] Onboarding can be manually reset from Settings.
- [x] No build warnings or Swift compiler crashes.
- [x] iPhone 17 Pro Max simulator layout verified on Today, Rituals, Insights, Settings.
----
-
-## PRIORITY: Wire up existing settings
-
-### 5) Haptic feedback ⚡
-- [ ] Add haptic feedback on habit check-in using `UIImpactFeedbackGenerator`.
-- [ ] Respect `hapticsEnabled` setting from SettingsStore.
-- [ ] Add haptics to other interactions (ritual creation, onboarding completion).
-
-### 6) Sound effects ⚡
-- [ ] Add completion sound when habit is checked in.
-- [ ] Respect `soundEnabled` setting from SettingsStore.
-- [ ] Use Bedrock `SoundManager` if available, or create audio service.
-
-### 7) Daily reminders (notifications) ⚡
-- [ ] Request notification permission when "Daily reminders" is enabled.
-- [ ] Schedule daily local notification at user-preferred time.
-- [ ] Add time picker to Settings for reminder time.
-- [ ] Cancel notifications when setting is disabled.
-- [ ] Handle notification authorization denied state in UI.
-
-### 8) Ritual pacing settings ⚡
-- [ ] Use `ritualLengthDays` setting when creating new rituals via `createQuickRitual()`.
-- [ ] Use `focusStyle` setting to affect ritual recommendations or insights.
-- [ ] Consider adding visual indicator of current pacing in Today view.
-
----
-
-## Lower priority
-
-### 9) Settings & product readiness
-- [x] Add a paid-app placeholder (e.g., "Pro unlock" copy) without backend requirements.
-- [ ] Confirm default settings and theme in Settings match Bedrock branding.
-
-### 10) Data & defaults
-- [ ] Confirm seed ritual creation and quick ritual creation behave as expected.
-- [ ] Validate SwiftData sync (if enabled) doesn't require any external API.
-
----
-
-## Future features
-
-### 11) History view
-- [ ] Add History tab or section to view completed/past rituals.
-- [ ] Show completion percentage for each past ritual arc.
-- [ ] Allow viewing habits and check-in history for past rituals.
-
-### 12) Ritual management
-- [ ] Add ability to create custom rituals (not just quick ritual).
-- [ ] Add ability to edit existing rituals (title, theme, habits).
-- [ ] Add ability to delete rituals.
-- [ ] Add ability to archive completed rituals.
-
-### 13) Insights enhancements
-- [ ] Show weekly/monthly trends.
-- [ ] Show streak data (consecutive days with all habits completed).
-- [ ] Add charts or visualizations for progress over time.
-
-### 14) Future enhancements
-- [ ] **HealthKit integration** – See plan: `.cursor/plans/healthkit_integration_plan_ce4f933c.plan.md`
+## 7) Future enhancements
+- [ ] **HealthKit integration** – Sync habit completions (water, mindfulness, exercise) to Apple Health. See plan: `.cursor/plans/healthkit_integration_plan_ce4f933c.plan.md`
+- [x] **History view** – View past/completed rituals with completion percentages. See plan: `.cursor/plans/calendar_history_view_88026c7b.plan.md`
+ - [x] Scrollable month calendar grid
+ - [x] Daily progress rings with color coding
+ - [x] Filter by ritual using horizontal pill picker
+ - [x] Tap day for detail sheet with habit list
+ - [x] New History tab in tab bar
+- [ ] **Ritual management** – Create, edit, delete, and archive rituals.
+- [x] **Insights enhancements** – Weekly/monthly trends, streak data, charts. See plan: `.cursor/plans/insights_overhaul_50b59fa7.plan.md`
+ - [x] Tappable insight cards with detail sheets
+ - [x] Explanations for each metric
+ - [x] Per-ritual breakdowns
+ - [x] Streak tracking (current & longest)
+ - [x] 7-day trend chart with sparkline preview
- [ ] **Widget** – Home screen widget showing today's progress.
- [ ] **Watch app** – Companion app for quick habit check-ins.