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

This commit is contained in:
Matt Bruce 2026-01-25 21:14:02 -06:00
parent e5b6f49033
commit 4139f4c530
16 changed files with 1046 additions and 36 deletions

View File

@ -3,6 +3,22 @@
"strings" : {
"" : {
},
"-%lld%% vs last week" : {
"comment" : "A description of how a user's usage has changed compared to the previous week. The argument is the percentage by which the usage has increased or decreased.",
"isCommentAutoGenerated" : true
},
"%@, Day %lld" : {
"comment" : "A view representing a milestone achievement in a ritual journey. The first argument is the title of the milestone. The second argument is the day on which the milestone was achieved.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$@, Day %2$lld"
}
}
}
},
"%lld" : {
"comment" : "A text view displaying the day number in a history calendar cell. The text is centered and has a small font size.",
@ -12,10 +28,22 @@
"comment" : "A label displaying the duration of a preset in days. The argument is the number of days the preset is active.",
"isCommentAutoGenerated" : true
},
"%lld days remaining" : {
"comment" : "A label showing how many days are left in the current ritual arc. The argument is the number of days remaining in the arc.",
"isCommentAutoGenerated" : true
},
"%lld habits" : {
"comment" : "A label showing the number of habits included in a ritual preset. The argument is the count of habits in the preset.",
"isCommentAutoGenerated" : true
},
"%lld habits left to complete today." : {
"comment" : "A tip suggesting that more habits need to be completed to reach the goal. The argument is the number of habits remaining.",
"isCommentAutoGenerated" : true
},
"%lld more days to beat your record!" : {
"comment" : "A tip suggesting that a user should work towards beating their current streak. The argument is the number of days remaining to beat the current streak.",
"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,
@ -54,6 +82,14 @@
"comment" : "A label describing the completion percentage of a task. The argument is the percentage.",
"isCommentAutoGenerated" : true
},
"%lld percent completion rate" : {
"comment" : "A value describing the completion rate of a habit. The value is a percentage.",
"isCommentAutoGenerated" : true
},
"%lld-day streak" : {
"comment" : "A badge indicating a user's current streak of completed habits. The number in the label is replaced with the actual streak length.",
"isCommentAutoGenerated" : true
},
"%lld%%" : {
"comment" : "A text label displaying a percentage. The argument is a value between 0.0 and 1.0.",
"isCommentAutoGenerated" : true
@ -62,6 +98,10 @@
"comment" : "A label that appears in the preset card to indicate that there are more habits than can be shown in the preview. The number inside the plus sign is the count of additional habits.",
"isCommentAutoGenerated" : true
},
"+%lld%% vs last week" : {
"comment" : "A textual representation of a percentage change, indicating whether it is positive or negative. The argument is the percentage change, as an integer.",
"isCommentAutoGenerated" : true
},
"5-minute meditation" : {
"comment" : "Title of a habit preset within a mindfulness ritual preset.",
"isCommentAutoGenerated" : true
@ -70,6 +110,10 @@
"comment" : "A heading for the 7-day trend section of an insight detail sheet.",
"isCommentAutoGenerated" : true
},
"21 days is when habits start to stick." : {
"comment" : "Tip text indicating that 21 days is when habits start to stick.",
"isCommentAutoGenerated" : true
},
"A calm mind sets the tone for a calm day." : {
"comment" : "Notes for the \"Morning Meditation\" ritual preset.",
"isCommentAutoGenerated" : true
@ -126,6 +170,10 @@
"comment" : "Description of what \"Gentle\" focus style means for the user.",
"isCommentAutoGenerated" : true
},
"A week of activity shows commitment!" : {
"comment" : "Tip suggesting that a week of activity indicates strong commitment to habits.",
"isCommentAutoGenerated" : true
},
"About" : {
"localizations" : {
"en" : {
@ -148,6 +196,14 @@
}
}
},
"Above weekly average" : {
"comment" : "Label text for a badge indicating that a user's habit completion rate is above the average for the week.",
"isCommentAutoGenerated" : true
},
"Achieved" : {
"comment" : "A description of a milestone that has been completed.",
"isCommentAutoGenerated" : true
},
"Across all rituals" : {
"extractionState" : "stale",
"localizations" : {
@ -214,6 +270,10 @@
"comment" : "A label indicating that a preset has been successfully added to the user's rituals.",
"isCommentAutoGenerated" : true
},
"Adding 1-2 more habits could help build momentum." : {
"comment" : "Tip to consider adding more habits to build momentum.",
"isCommentAutoGenerated" : true
},
"Adjust arc duration" : {
"localizations" : {
"en" : {
@ -243,6 +303,9 @@
"All" : {
"comment" : "Title for the \"All\" option in the history ritual filter picker.",
"isCommentAutoGenerated" : true
},
"All habits complete! Great work today." : {
},
"Anytime" : {
"comment" : "Name of the \"Anytime\" time of day option in the Ritual editor.",
@ -256,6 +319,10 @@
"comment" : "Name of a habit within a self-care ritual preset.",
"isCommentAutoGenerated" : true
},
"Arc complete!" : {
"comment" : "A message displayed when a ritual's streak reaches its maximum value.",
"isCommentAutoGenerated" : true
},
"Archive" : {
"comment" : "A button that archives a ritual, hiding it from the user's active list but preserving its history.",
"isCommentAutoGenerated" : true
@ -294,6 +361,10 @@
}
}
},
"Below weekly average" : {
"comment" : "Label text for a badge indicating that their habit completion rate is below the average for the week.",
"isCommentAutoGenerated" : true
},
"Body scan for tension" : {
"comment" : "Habit title for a mindfulness ritual that involves scanning the body for tension.",
"isCommentAutoGenerated" : true
@ -474,6 +545,14 @@
"comment" : "A label indicating that a feature is coming soon.",
"isCommentAutoGenerated" : true
},
"Complete" : {
"comment" : "Title of the final milestone in a 28-day ritual arc.",
"isCommentAutoGenerated" : true
},
"Complete all habits today to start a new streak." : {
"comment" : "Tip for the \"Habits today\" insight card, encouraging users to complete all their habits to start a new streak.",
"isCommentAutoGenerated" : true
},
"Completed" : {
"localizations" : {
"en" : {
@ -524,6 +603,10 @@
},
"Consecutive perfect days" : {
},
"Consider focusing on fewer habits for better consistency." : {
"comment" : "Tip to consider focusing on fewer habits for better consistency.",
"isCommentAutoGenerated" : true
},
"Create" : {
"comment" : "A button label that says \"Create\".",
@ -645,6 +728,10 @@
"comment" : "Label for the x-axis in the mini sparkline chart within an `InsightCardView`.",
"isCommentAutoGenerated" : true
},
"Day %lld" : {
"comment" : "A small label displaying the day and title of a milestone. The first argument is the day of the milestone. The second argument is the title of the milestone.",
"isCommentAutoGenerated" : true
},
"Day %lld of %lld" : {
"localizations" : {
"en" : {
@ -907,6 +994,14 @@
"comment" : "Title of a ritual preset that encourages transitioning to rest at the end of the day.",
"isCommentAutoGenerated" : true
},
"Every habit counts. You've got this." : {
"comment" : "Motivational message for a completion rate between 0.25 and 0.5.",
"isCommentAutoGenerated" : true
},
"Excellent consistency! You're building strong habits." : {
"comment" : "Tip provided in the \"Completion\" insight card when the user has a high completion rate.",
"isCommentAutoGenerated" : true
},
"Faster iCloud synchronization" : {
},
@ -937,6 +1032,10 @@
"comment" : "Title of a habit in a ritual preset focused on practicing gratitude.",
"isCommentAutoGenerated" : true
},
"First Day" : {
"comment" : "Title of the first milestone in a 28-day ritual arc.",
"isCommentAutoGenerated" : true
},
"Focus Reset" : {
"comment" : "Title of a ritual preset that encourages users to refocus when they feel scattered.",
"isCommentAutoGenerated" : true
@ -1135,10 +1234,18 @@
"comment" : "Title of a ritual preset focused on practicing gratitude.",
"isCommentAutoGenerated" : true
},
"Great progress! Almost there." : {
"comment" : "Motivational message for a completion rate between 0.75 and 1.0.",
"isCommentAutoGenerated" : true
},
"Habit name" : {
"comment" : "A text field label for entering a habit name.",
"isCommentAutoGenerated" : true
},
"Habit Performance" : {
"comment" : "A title for a view that shows how well a user is performing various habits.",
"isCommentAutoGenerated" : true
},
"Habits" : {
"localizations" : {
"en" : {
@ -1187,6 +1294,10 @@
}
}
},
"Halfway" : {
"comment" : "Title of a milestone achievement halfway through a 28-day ritual arc.",
"isCommentAutoGenerated" : true
},
"Haptics" : {
"localizations" : {
"en" : {
@ -1385,6 +1496,10 @@
"comment" : "Title of a ritual preset that encourages regular physical activity during the day.",
"isCommentAutoGenerated" : true
},
"Milestones" : {
"comment" : "A label displayed above the list of milestones.",
"isCommentAutoGenerated" : true
},
"Mindful minute" : {
"localizations" : {
"en" : {
@ -1579,6 +1694,10 @@
}
}
},
"Not yet achieved" : {
"comment" : "A description of a milestone that the user has not yet achieved.",
"isCommentAutoGenerated" : true
},
"Notes" : {
"localizations" : {
"en" : {
@ -1605,6 +1724,18 @@
"comment" : "Subtitle text for the reminder toggle when notifications are disabled in settings.",
"isCommentAutoGenerated" : true
},
"On track" : {
"comment" : "Label for a badge indicating that a user is on track with their habit completions.",
"isCommentAutoGenerated" : true
},
"One Week" : {
"comment" : "Title for a milestone that occurs one week into a ritual arc.",
"isCommentAutoGenerated" : true
},
"Perfect day! You crushed it." : {
"comment" : "A motivational message displayed when a user achieves 100% completion on a ritual.",
"isCommentAutoGenerated" : true
},
"Plan the week ahead" : {
"comment" : "Habit title for a ritual preset that helps users plan their week ahead.",
"isCommentAutoGenerated" : true
@ -2056,6 +2187,10 @@
"comment" : "Theme of the \"Sleep Preparation\" ritual preset.",
"isCommentAutoGenerated" : true
},
"Small steps lead to big changes." : {
"comment" : "A motivational message indicating that small steps can lead to significant changes.",
"isCommentAutoGenerated" : true
},
"Soft landings" : {
"localizations" : {
"en" : {
@ -2078,6 +2213,10 @@
}
}
},
"Solid effort. Keep building momentum." : {
"comment" : "A motivational message for a completion rate between 0.5 and 0.75.",
"isCommentAutoGenerated" : true
},
"Sound" : {
"localizations" : {
"en" : {
@ -2100,6 +2239,10 @@
}
}
},
"Stable" : {
"comment" : "Description of a trend direction when there is no significant change.",
"isCommentAutoGenerated" : true
},
"Start with stillness" : {
"comment" : "Theme of the \"Morning Meditation\" ritual preset.",
"isCommentAutoGenerated" : true
@ -2450,6 +2593,10 @@
"comment" : "An alert message that appears when the user attempts to delete a ritual. It explains that deleting the ritual cannot be undone.",
"isCommentAutoGenerated" : true
},
"Three Weeks" : {
"comment" : "Title of a milestone that is achieved after three weeks of a ritual journey.",
"isCommentAutoGenerated" : true
},
"Tidy workspace" : {
"comment" : "Habit within a ritual preset that encourages tidying one's workspace.",
"isCommentAutoGenerated" : true
@ -2466,6 +2613,10 @@
"comment" : "A label for the time of day picker in the ritual edit sheet.",
"isCommentAutoGenerated" : true
},
"Tips" : {
"comment" : "A section header that indicates that the view contains tips.",
"isCommentAutoGenerated" : true
},
"Today" : {
"localizations" : {
"en" : {
@ -2492,6 +2643,10 @@
"comment" : "Explanation of the \"Today's progress\" insight card in the Ritual Insights section.",
"isCommentAutoGenerated" : true
},
"Tomorrow is a fresh start." : {
"comment" : "A motivational message displayed when the user's completion rate is below 1%.",
"isCommentAutoGenerated" : true
},
"Total arcs in motion" : {
"extractionState" : "stale",
"localizations" : {
@ -2542,6 +2697,18 @@
"comment" : "Theme of the \"Evening Wind-Down\" ritual preset.",
"isCommentAutoGenerated" : true
},
"Trending down" : {
"comment" : "Accessibility label for a trend direction of \"down\".",
"isCommentAutoGenerated" : true
},
"Trending up" : {
"comment" : "Accessibility label for a trend direction indicating an increase.",
"isCommentAutoGenerated" : true
},
"Try starting with just one habit to build momentum." : {
"comment" : "Tip to start with a single habit to build momentum.",
"isCommentAutoGenerated" : true
},
"Unlimited Rituals" : {
"comment" : "Description of a feature in the \"Pro\" upgrade section, describing that users can create as many rituals as they need.",
"isCommentAutoGenerated" : true
@ -2649,6 +2816,10 @@
"comment" : "Habit title for a ritual preset that encourages the user to write down their thoughts.",
"isCommentAutoGenerated" : true
},
"You're at your best streak! Keep it going." : {
"comment" : "Tip provided when the user is at their longest streak and it is greater than zero.",
"isCommentAutoGenerated" : true
},
"Your active and recent arcs" : {
"localizations" : {
"en" : {

View File

@ -0,0 +1,55 @@
//
// Milestone.swift
// Andromida
//
// Represents a milestone achievement within a ritual arc.
//
import Foundation
/// A milestone that can be achieved during a ritual journey.
struct Milestone: Identifiable {
let id = UUID()
let day: Int
let title: String
let symbolName: String
let isAchieved: Bool
/// Standard milestones for a 28-day ritual arc.
static func standardMilestones(currentDay: Int, totalDays: Int) -> [Milestone] {
let halfwayDay = totalDays / 2
return [
Milestone(
day: 1,
title: String(localized: "First Day"),
symbolName: "star.fill",
isAchieved: currentDay >= 1
),
Milestone(
day: 7,
title: String(localized: "One Week"),
symbolName: "7.circle.fill",
isAchieved: currentDay >= 7
),
Milestone(
day: halfwayDay,
title: String(localized: "Halfway"),
symbolName: "flag.fill",
isAchieved: currentDay >= halfwayDay
),
Milestone(
day: 21,
title: String(localized: "Three Weeks"),
symbolName: "brain.fill",
isAchieved: currentDay >= 21
),
Milestone(
day: totalDays,
title: String(localized: "Complete"),
symbolName: "trophy.fill",
isAchieved: currentDay >= totalDays
)
].filter { $0.day <= totalDays }
}
}

View File

@ -0,0 +1,53 @@
//
// TrendDirection.swift
// Andromida
//
// Represents the direction of a trend for insight visualization.
//
import SwiftUI
import Bedrock
/// Represents the direction of change in a metric.
enum TrendDirection {
case up
case down
case stable
var symbolName: String {
switch self {
case .up: return "arrow.up.right"
case .down: return "arrow.down.right"
case .stable: return "arrow.right"
}
}
var color: Color {
switch self {
case .up: return AppStatus.success
case .down: return AppStatus.warning
case .stable: return AppTextColors.secondary
}
}
var accessibilityLabel: String {
switch self {
case .up: return String(localized: "Trending up")
case .down: return String(localized: "Trending down")
case .stable: return String(localized: "Stable")
}
}
/// Determines trend direction based on percentage change.
/// - Parameter percentageChange: The change as a decimal (e.g., 0.1 for 10% increase)
/// - Returns: The appropriate trend direction
static func from(percentageChange: Double) -> TrendDirection {
if percentageChange > 0.05 {
return .up
} else if percentageChange < -0.05 {
return .down
} else {
return .stable
}
}
}

View File

@ -0,0 +1,104 @@
//
// InsightTipsProviding.swift
// Andromida
//
// Protocol for generating contextual tips based on insight metrics.
//
import Foundation
/// Context information for generating insight tips.
struct InsightContext {
let completionRate: Double
let currentStreak: Int
let longestStreak: Int
let daysActive: Int
let totalHabits: Int
let completedToday: Int
}
/// Protocol for generating contextual tips for insights.
protocol InsightTipsProviding {
/// Generates tips based on the insight card and user context.
/// - Parameters:
/// - card: The insight card to generate tips for
/// - context: Current user metrics context
/// - Returns: Array of tip strings
func tips(for card: InsightCard, context: InsightContext) -> [String]
}
/// Default implementation providing standard tips.
struct DefaultInsightTipsProvider: InsightTipsProviding {
func tips(for card: InsightCard, context: InsightContext) -> [String] {
switch card.title {
case String(localized: "Active"):
return activeTips(context: context)
case String(localized: "Streak"):
return streakTips(context: context)
case String(localized: "Habits today"):
return habitsTips(context: context)
case String(localized: "Completion"):
return completionTips(context: context)
case String(localized: "Days Active"):
return daysActiveTips(context: context)
default:
return []
}
}
private func activeTips(context: InsightContext) -> [String] {
var tips: [String] = []
if context.totalHabits > 10 {
tips.append(String(localized: "Consider focusing on fewer habits for better consistency."))
}
if context.totalHabits < 3 {
tips.append(String(localized: "Adding 1-2 more habits could help build momentum."))
}
return tips
}
private func streakTips(context: InsightContext) -> [String] {
var tips: [String] = []
if context.currentStreak == 0 {
tips.append(String(localized: "Complete all habits today to start a new streak."))
} else if context.currentStreak < context.longestStreak {
let daysToGo = context.longestStreak - context.currentStreak
tips.append(String(localized: "\(daysToGo) more days to beat your record!"))
} else if context.currentStreak == context.longestStreak && context.currentStreak > 0 {
tips.append(String(localized: "You're at your best streak! Keep it going."))
}
return tips
}
private func habitsTips(context: InsightContext) -> [String] {
var tips: [String] = []
let remaining = context.totalHabits - context.completedToday
if remaining > 0 {
tips.append(String(localized: "\(remaining) habits left to complete today."))
} else if context.totalHabits > 0 {
tips.append(String(localized: "All habits complete! Great work today."))
}
return tips
}
private func completionTips(context: InsightContext) -> [String] {
var tips: [String] = []
if context.completionRate < 0.5 {
tips.append(String(localized: "Try starting with just one habit to build momentum."))
} else if context.completionRate >= 0.8 {
tips.append(String(localized: "Excellent consistency! You're building strong habits."))
}
return tips
}
private func daysActiveTips(context: InsightContext) -> [String] {
var tips: [String] = []
if context.daysActive >= 7 {
tips.append(String(localized: "A week of activity shows commitment!"))
}
if context.daysActive >= 21 {
tips.append(String(localized: "21 days is when habits start to stick."))
}
return tips
}
}

View File

@ -14,4 +14,16 @@ protocol RitualStoreProviding {
func completionSummary(for ritual: Ritual) -> String
func insightCards() -> [InsightCard]
func createQuickRitual()
// Enhanced Analytics
func weeklyAverageForDate(_ date: Date) -> Double
func streakIncluding(_ date: Date) -> Int?
func daysRemaining(for ritual: Ritual) -> Int
func streakForRitual(_ ritual: Ritual) -> Int
func habitCompletionRates(for ritual: Ritual) -> [(habit: Habit, rate: Double)]
func milestonesAchieved(for ritual: Ritual) -> [Milestone]
func weekOverWeekChange() -> Double
func motivationalMessage(for rate: Double) -> String
func insightContext() -> InsightContext
func completionRate(for date: Date, ritual: Ritual?) -> Double
}

View File

@ -570,6 +570,178 @@ final class RitualStore: RitualStoreProviding {
return habit.completedDayIDs.contains(dayID)
}
// MARK: - Enhanced Analytics
/// Returns the weekly average completion rate for the week containing the given date.
/// - Parameter date: A date within the week to analyze
/// - Returns: Average completion rate from 0.0 to 1.0
func weeklyAverageForDate(_ date: Date) -> Double {
guard let weekStart = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date)) else {
return 0
}
var totalRate = 0.0
var daysWithData = 0
for dayOffset in 0..<7 {
guard let day = calendar.date(byAdding: .day, value: dayOffset, to: weekStart) else { continue }
let rate = completionRate(for: day)
if rate > 0 || hasAnyHabitsOnDate(day) {
totalRate += rate
daysWithData += 1
}
}
return daysWithData > 0 ? totalRate / Double(daysWithData) : 0
}
/// Checks if there were any habits tracked on the given date.
private func hasAnyHabitsOnDate(_ date: Date) -> Bool {
let dayID = dayIdentifier(for: date)
return rituals.flatMap { $0.habits }.contains { habit in
habit.completedDayIDs.contains(dayID)
}
}
/// Returns the streak length that includes the given date, or nil if the date wasn't a perfect day.
/// - Parameter date: The date to check
/// - Returns: The streak length if the date was part of a streak, nil otherwise
func streakIncluding(_ date: Date) -> Int? {
let dayID = dayIdentifier(for: date)
let perfect = perfectDays()
guard perfect.contains(dayID) else { return nil }
// Count backwards from the date
var streakBefore = 0
var checkDate = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: date)) ?? date
while perfect.contains(dayIdentifier(for: checkDate)) {
streakBefore += 1
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
}
// Count forwards from the date
var streakAfter = 0
checkDate = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: date)) ?? date
while perfect.contains(dayIdentifier(for: checkDate)) {
streakAfter += 1
checkDate = calendar.date(byAdding: .day, value: 1, to: checkDate) ?? checkDate
}
return streakBefore + 1 + streakAfter
}
/// Returns the number of days remaining in the ritual arc.
/// - Parameter ritual: The ritual to check
/// - Returns: Days remaining (0 if completed or past end date)
func daysRemaining(for ritual: Ritual) -> Int {
let today = calendar.startOfDay(for: Date())
let start = calendar.startOfDay(for: ritual.startDate)
guard let endDate = calendar.date(byAdding: .day, value: ritual.durationDays - 1, to: start) else {
return 0
}
let days = calendar.dateComponents([.day], from: today, to: endDate).day ?? 0
return max(0, days)
}
/// Returns the current streak for a specific ritual.
/// - Parameter ritual: The ritual to check
/// - Returns: Current streak count for this ritual only
func streakForRitual(_ ritual: Ritual) -> Int {
guard !ritual.habits.isEmpty else { return 0 }
var streak = 0
var checkDate = calendar.startOfDay(for: Date())
while true {
let dayID = dayIdentifier(for: checkDate)
let allCompleted = ritual.habits.allSatisfy { $0.completedDayIDs.contains(dayID) }
if allCompleted {
streak += 1
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
} else {
break
}
}
return streak
}
/// Returns completion rates for each habit in a ritual.
/// - Parameter ritual: The ritual to analyze
/// - Returns: Array of tuples with habit and its completion rate
func habitCompletionRates(for ritual: Ritual) -> [(habit: Habit, rate: Double)] {
let currentDay = ritualDayIndex(for: ritual)
guard currentDay > 0 else { return ritual.habits.map { ($0, 0.0) } }
return ritual.habits.map { habit in
let completedDays = habit.completedDayIDs.count
let rate = Double(completedDays) / Double(currentDay)
return (habit, min(rate, 1.0))
}
}
/// Returns milestones for a ritual with achievement status.
/// - Parameter ritual: The ritual to check
/// - Returns: Array of milestones with achievement status
func milestonesAchieved(for ritual: Ritual) -> [Milestone] {
let currentDay = ritualDayIndex(for: ritual)
return Milestone.standardMilestones(currentDay: currentDay, totalDays: ritual.durationDays)
}
/// Returns the week-over-week change in completion rate.
/// - Returns: Percentage change (e.g., 0.15 for 15% improvement)
func weekOverWeekChange() -> Double {
let today = calendar.startOfDay(for: Date())
guard let lastWeekStart = calendar.date(byAdding: .day, value: -7, to: today) else {
return 0
}
let thisWeekAvg = weeklyAverageForDate(today)
let lastWeekAvg = weeklyAverageForDate(lastWeekStart)
guard lastWeekAvg > 0 else { return thisWeekAvg > 0 ? 1.0 : 0 }
return (thisWeekAvg - lastWeekAvg) / lastWeekAvg
}
/// Returns a motivational message based on the completion rate.
/// - Parameter rate: The completion rate (0.0 to 1.0)
/// - Returns: A localized motivational message
func motivationalMessage(for rate: Double) -> String {
switch rate {
case 1.0:
return String(localized: "Perfect day! You crushed it.")
case 0.75..<1.0:
return String(localized: "Great progress! Almost there.")
case 0.5..<0.75:
return String(localized: "Solid effort. Keep building momentum.")
case 0.25..<0.5:
return String(localized: "Every habit counts. You've got this.")
case 0.01..<0.25:
return String(localized: "Small steps lead to big changes.")
default:
return String(localized: "Tomorrow is a fresh start.")
}
}
/// Returns the insight context for tips generation.
func insightContext() -> InsightContext {
let totalHabits = rituals.flatMap { $0.habits }.count
let completedToday = rituals.flatMap { $0.habits }.filter { isHabitCompletedToday($0) }.count
let rate = totalHabits > 0 ? Double(completedToday) / Double(totalHabits) : 0
return InsightContext(
completionRate: rate,
currentStreak: currentStreak(),
longestStreak: longestStreak(),
daysActive: datesWithActivity().count,
totalHabits: totalHabits,
completedToday: completedToday
)
}
// MARK: - Debug / Demo Data
#if DEBUG

View File

@ -13,6 +13,7 @@ struct ProgressRing: View {
let progress: Double // 0.0 to 1.0
let size: CGFloat
var lineWidth: CGFloat = 3
var showPercentage: Bool = false
private var color: Color {
if progress >= 1.0 { return AppStatus.success }
@ -38,6 +39,15 @@ struct ProgressRing: View {
)
.rotationEffect(.degrees(-90))
.animation(.easeOut(duration: Design.Animation.standard), value: progress)
// Percentage text inside ring
if showPercentage {
Text("\(Int(progress * 100))%")
.font(.system(size: size * 0.25, weight: .bold))
.foregroundStyle(AppTextColors.primary)
.minimumScaleFactor(0.8)
.lineLimit(1)
}
}
.frame(width: size, height: size)
.accessibilityElement(children: .ignore)
@ -46,12 +56,20 @@ struct ProgressRing: View {
}
#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)
VStack(spacing: Design.Spacing.large) {
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)
}
HStack(spacing: Design.Spacing.large) {
ProgressRing(progress: 0.0, size: 80, lineWidth: 6, showPercentage: true)
ProgressRing(progress: 0.5, size: 80, lineWidth: 6, showPercentage: true)
ProgressRing(progress: 1.0, size: 80, lineWidth: 6, showPercentage: true)
}
}
.padding()
.background(AppSurface.primary)

View File

@ -12,6 +12,7 @@ import Bedrock
struct HistoryDayDetailSheet: View {
let date: Date
let completions: [HabitCompletion]
let store: RitualStore
@Environment(\.dismiss) private var dismiss
private var dateTitle: String {
@ -30,6 +31,14 @@ struct HistoryDayDetailSheet: View {
completions.filter { $0.isCompleted }.count
}
private var weeklyAverage: Double {
store.weeklyAverageForDate(date)
}
private var streakLength: Int? {
store.streakIncluding(date)
}
var body: some View {
NavigationStack {
ScrollView(.vertical, showsIndicators: false) {
@ -37,6 +46,11 @@ struct HistoryDayDetailSheet: View {
// Summary header
summaryHeader
// Context badges (comparison, streak)
if !completions.isEmpty {
contextSection
}
// Habit list grouped by ritual
if completions.isEmpty {
emptyState
@ -93,7 +107,7 @@ struct HistoryDayDetailSheet: View {
private var summaryHeader: some View {
VStack(spacing: Design.Spacing.medium) {
ProgressRing(progress: completionRate, size: 80, lineWidth: 6)
ProgressRing(progress: completionRate, size: 80, lineWidth: 6, showPercentage: true)
Text("\(completedCount) of \(completions.count)")
.font(.title2)
@ -103,11 +117,80 @@ struct HistoryDayDetailSheet: View {
Text(String(localized: "habits completed"))
.font(.subheadline)
.foregroundStyle(AppTextColors.secondary)
// Motivational message
if !completions.isEmpty {
Text(store.motivationalMessage(for: completionRate))
.font(.subheadline)
.foregroundStyle(AppTextColors.tertiary)
.italic()
.padding(.top, Design.Spacing.xSmall)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, Design.Spacing.large)
}
private var contextSection: some View {
VStack(spacing: Design.Spacing.small) {
HStack(spacing: Design.Spacing.medium) {
// Comparison badge
comparisonBadge
// Streak badge (if applicable)
if let streak = streakLength {
streakBadge(streak)
}
}
}
.frame(maxWidth: .infinity)
}
private var comparisonBadge: some View {
let difference = completionRate - weeklyAverage
let isAbove = difference > 0.05
let isBelow = difference < -0.05
let text: String
let symbolName: String
let color: Color
if isAbove {
text = String(localized: "Above weekly average")
symbolName = "arrow.up.circle.fill"
color = AppStatus.success
} else if isBelow {
text = String(localized: "Below weekly average")
symbolName = "arrow.down.circle.fill"
color = AppStatus.warning
} else {
text = String(localized: "On track")
symbolName = "equal.circle.fill"
color = AppAccent.primary
}
return Label(text, systemImage: symbolName)
.font(.caption)
.foregroundStyle(color)
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small)
.background(color.opacity(Design.Opacity.subtle))
.clipShape(.capsule)
}
private func streakBadge(_ streak: Int) -> some View {
Label(
String(localized: "\(streak)-day streak"),
systemImage: "flame.fill"
)
.font(.caption)
.foregroundStyle(AppStatus.success)
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small)
.background(AppStatus.success.opacity(Design.Opacity.subtle))
.clipShape(.capsule)
}
private var habitList: some View {
let groupedByRitual = Dictionary(grouping: completions) { $0.ritualTitle }
let sortedRituals = groupedByRitual.keys.sorted()
@ -160,6 +243,7 @@ struct HistoryDayDetailSheet: View {
#Preview {
HistoryDayDetailSheet(
date: Date(),
completions: []
completions: [],
store: RitualStore.preview
)
}

View File

@ -90,7 +90,8 @@ struct HistoryView: View {
.sheet(item: $selectedDateItem) { item in
HistoryDayDetailSheet(
date: item.date,
completions: store.habitCompletions(for: item.date, ritual: selectedRitual)
completions: store.habitCompletions(for: item.date, ritual: selectedRitual),
store: store
)
}
}

View File

@ -4,6 +4,7 @@ import Charts
struct InsightCardView: View {
let card: InsightCard
let store: RitualStore
@State private var showingDetail = false
var body: some View {
@ -14,7 +15,7 @@ struct InsightCardView: View {
}
.buttonStyle(.plain)
.sheet(isPresented: $showingDetail) {
InsightDetailSheet(card: card)
InsightDetailSheet(card: card, store: store)
}
}
@ -78,13 +79,16 @@ struct InsightCardView: View {
}
#Preview {
InsightCardView(card: InsightCard(
title: "Completion",
value: "72%",
caption: "Today's progress",
explanation: "Your completion percentage for today across all rituals.",
symbolName: "chart.bar.fill"
))
InsightCardView(
card: InsightCard(
title: "Completion",
value: "72%",
caption: "Today's progress",
explanation: "Your completion percentage for today across all rituals.",
symbolName: "chart.bar.fill"
),
store: RitualStore.preview
)
.padding(Design.Spacing.large)
.background(AppSurface.primary)
}

View File

@ -11,8 +11,23 @@ import Charts
struct InsightDetailSheet: View {
let card: InsightCard
let store: RitualStore
@Environment(\.dismiss) private var dismiss
private let tipsProvider: InsightTipsProviding = DefaultInsightTipsProvider()
private var weekOverWeekChange: Double {
store.weekOverWeekChange()
}
private var trendDirection: TrendDirection {
TrendDirection.from(percentageChange: weekOverWeekChange)
}
private var tips: [String] {
tipsProvider.tips(for: card, context: store.insightContext())
}
var body: some View {
NavigationStack {
ScrollView(.vertical, showsIndicators: false) {
@ -20,6 +35,11 @@ struct InsightDetailSheet: View {
// Header with icon and value
headerSection
// Trend indicator (for cards with trend data)
if card.trendData != nil {
trendIndicatorSection
}
// Chart (if trend data available)
if let trendData = card.trendData, !trendData.isEmpty {
chartSection(trendData)
@ -28,6 +48,11 @@ struct InsightDetailSheet: View {
// Explanation
explanationSection
// Tips section (if any tips available)
if !tips.isEmpty {
tipsSection
}
// Breakdown (if available)
if let breakdown = card.breakdown, !breakdown.isEmpty {
breakdownSection(breakdown)
@ -74,6 +99,71 @@ struct InsightDetailSheet: View {
.padding(.vertical, Design.Spacing.large)
}
// MARK: - Trend Indicator Section
private var trendIndicatorSection: some View {
HStack(spacing: Design.Spacing.medium) {
// Trend direction badge
HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: trendDirection.symbolName)
.font(.caption)
Text(trendDirection.accessibilityLabel)
.font(.caption)
}
.foregroundStyle(trendDirection.color)
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small)
.background(trendDirection.color.opacity(Design.Opacity.subtle))
.clipShape(.capsule)
// Week-over-week change
if abs(weekOverWeekChange) > 0.01 {
let changePercent = Int(abs(weekOverWeekChange) * 100)
let changeText = weekOverWeekChange >= 0
? String(localized: "+\(changePercent)% vs last week")
: String(localized: "-\(changePercent)% vs last week")
Text(changeText)
.font(.caption)
.foregroundStyle(AppTextColors.secondary)
}
Spacer()
}
.frame(maxWidth: .infinity)
}
// MARK: - Tips Section
private var tipsSection: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
Label(String(localized: "Tips"), systemImage: "lightbulb.fill")
.font(.headline)
.foregroundStyle(AppTextColors.primary)
VStack(alignment: .leading, spacing: Design.Spacing.small) {
ForEach(tips, id: \.self) { tip in
HStack(alignment: .top, spacing: Design.Spacing.small) {
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(AppAccent.primary)
.padding(.top, Design.Spacing.xSmall)
Text(tip)
.font(.subheadline)
.foregroundStyle(AppTextColors.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
.padding(Design.Spacing.large)
.frame(maxWidth: .infinity, alignment: .leading)
.background(AppAccent.primary.opacity(Design.Opacity.subtle))
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
}
// MARK: - Explanation Section
private var explanationSection: some View {
@ -182,15 +272,18 @@ struct InsightDetailSheet: View {
}
#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")
]
))
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")
]
),
store: RitualStore.preview
)
}

View File

@ -18,7 +18,7 @@ struct InsightsView: View {
LazyVGrid(columns: columns, spacing: Design.Spacing.medium) {
ForEach(store.insightCards()) { card in
InsightCardView(card: card)
InsightCardView(card: card, store: store)
}
}
}

View File

@ -0,0 +1,92 @@
//
// HabitPerformanceView.swift
// Andromida
//
// Displays habit completion rates for a ritual.
//
import SwiftUI
import Bedrock
/// A view showing habit completion performance within a ritual.
struct HabitPerformanceView: View {
let habitRates: [(habit: Habit, rate: Double)]
private var sortedByRate: [(habit: Habit, rate: Double)] {
habitRates.sorted { $0.rate > $1.rate }
}
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
Text(String(localized: "Habit Performance"))
.font(.headline)
.foregroundStyle(AppTextColors.primary)
VStack(spacing: Design.Spacing.xSmall) {
ForEach(sortedByRate, id: \.habit.id) { item in
habitRow(item.habit, rate: item.rate)
}
}
}
.padding(Design.Spacing.large)
.frame(maxWidth: .infinity, alignment: .leading)
.background(AppSurface.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
}
private func habitRow(_ habit: Habit, rate: Double) -> some View {
VStack(spacing: Design.Spacing.xSmall) {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: habit.symbolName)
.foregroundStyle(colorForRate(rate))
.frame(width: AppMetrics.Size.iconMedium)
Text(habit.title)
.font(.subheadline)
.foregroundStyle(AppTextColors.primary)
Spacer()
Text("\(Int(rate * 100))%")
.font(.subheadline)
.bold()
.foregroundStyle(colorForRate(rate))
}
// Progress bar
GeometryReader { geometry in
ZStack(alignment: .leading) {
Rectangle()
.fill(AppBorder.subtle)
.frame(height: Design.LineWidth.thick)
Rectangle()
.fill(colorForRate(rate))
.frame(width: geometry.size.width * min(rate, 1.0), height: Design.LineWidth.thick)
}
}
.frame(height: Design.LineWidth.thick)
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
}
.padding(.vertical, Design.Spacing.xSmall)
.accessibilityElement(children: .combine)
.accessibilityLabel(habit.title)
.accessibilityValue(String(localized: "\(Int(rate * 100)) percent completion rate"))
}
private func colorForRate(_ rate: Double) -> Color {
if rate >= 0.8 { return AppStatus.success }
if rate >= 0.5 { return AppAccent.primary }
return AppTextColors.tertiary
}
}
#Preview {
HabitPerformanceView(habitRates: [
(Habit(title: "Meditate", symbolName: "brain.fill"), 0.85),
(Habit(title: "Exercise", symbolName: "figure.walk"), 0.65),
(Habit(title: "Read", symbolName: "book.fill"), 0.40)
])
.padding(Design.Spacing.large)
.background(AppSurface.primary)
}

View File

@ -0,0 +1,74 @@
//
// RitualMilestonesView.swift
// Andromida
//
// Displays milestone achievements for a ritual journey.
//
import SwiftUI
import Bedrock
/// A view showing milestone achievements for a ritual.
struct RitualMilestonesView: View {
let milestones: [Milestone]
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
Text(String(localized: "Milestones"))
.font(.headline)
.foregroundStyle(AppTextColors.primary)
HStack(spacing: Design.Spacing.small) {
ForEach(milestones) { milestone in
milestoneItem(milestone)
}
}
}
.padding(Design.Spacing.large)
.frame(maxWidth: .infinity, alignment: .leading)
.background(AppSurface.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
}
private func milestoneItem(_ milestone: Milestone) -> some View {
VStack(spacing: Design.Spacing.xSmall) {
ZStack {
Circle()
.fill(milestone.isAchieved ? AppStatus.success.opacity(Design.Opacity.medium) : AppSurface.secondary)
.frame(width: AppMetrics.Size.milestoneIcon, height: AppMetrics.Size.milestoneIcon)
Image(systemName: milestone.symbolName)
.font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(milestone.isAchieved ? AppStatus.success : AppTextColors.tertiary)
}
Text("Day \(milestone.day)")
.font(.caption2)
.foregroundStyle(milestone.isAchieved ? AppTextColors.primary : AppTextColors.tertiary)
Text(milestone.title)
.font(.caption2)
.foregroundStyle(milestone.isAchieved ? AppTextColors.secondary : AppTextColors.tertiary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
.accessibilityElement(children: .combine)
.accessibilityLabel("\(milestone.title), Day \(milestone.day)")
.accessibilityValue(milestone.isAchieved ? String(localized: "Achieved") : String(localized: "Not yet achieved"))
}
}
// MARK: - AppMetrics Extension
extension AppMetrics.Size {
static let milestoneIcon: CGFloat = 36
}
#Preview {
VStack {
RitualMilestonesView(milestones: Milestone.standardMilestones(currentDay: 10, totalDays: 28))
}
.padding(Design.Spacing.large)
.background(AppSurface.primary)
}

View File

@ -16,6 +16,22 @@ struct RitualDetailView: View {
self.ritual = ritual
}
private var daysRemaining: Int {
store.daysRemaining(for: ritual)
}
private var ritualStreak: Int {
store.streakForRitual(ritual)
}
private var milestones: [Milestone] {
store.milestonesAchieved(for: ritual)
}
private var habitRates: [(habit: Habit, rate: Double)] {
store.habitCompletionRates(for: ritual)
}
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: Design.Spacing.large) {
@ -31,8 +47,19 @@ struct RitualDetailView: View {
progress: store.ritualProgress(for: ritual)
)
// Status badges
// Status badges with time remaining and streak
statusBadges
// Time remaining and streak info
timeAndStreakSection
// Milestones
RitualMilestonesView(milestones: milestones)
// Habit performance
if !habitRates.isEmpty {
HabitPerformanceView(habitRates: habitRates)
}
// Notes
if !ritual.notes.isEmpty {
@ -194,6 +221,56 @@ struct RitualDetailView: View {
Spacer()
}
}
// MARK: - Time and Streak Section
private var timeAndStreakSection: some View {
HStack(spacing: Design.Spacing.medium) {
// Days remaining
if daysRemaining > 0 {
HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "hourglass")
.font(.caption)
Text(String(localized: "\(daysRemaining) days remaining"))
.font(.caption)
}
.foregroundStyle(AppTextColors.secondary)
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small)
.background(AppSurface.card)
.clipShape(.capsule)
} else {
HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "checkmark.seal.fill")
.font(.caption)
Text(String(localized: "Arc complete!"))
.font(.caption)
}
.foregroundStyle(AppStatus.success)
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small)
.background(AppStatus.success.opacity(Design.Opacity.subtle))
.clipShape(.capsule)
}
// Ritual streak
if ritualStreak > 0 {
HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "flame.fill")
.font(.caption)
Text(String(localized: "\(ritualStreak)-day streak"))
.font(.caption)
}
.foregroundStyle(AppStatus.success)
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small)
.background(AppStatus.success.opacity(Design.Opacity.subtle))
.clipShape(.capsule)
}
Spacer()
}
}
}
#Preview {

View File

@ -76,12 +76,12 @@ struct TodayRitualSectionView: View {
.sherpaTag(RitualsOnboardingTag.firstHabit)
}
ForEach(habitRows.dropFirst()) { habit in
TodayHabitRowView(
title: habit.title,
symbolName: habit.symbolName,
isCompleted: habit.isCompleted,
action: habit.action
)
TodayHabitRowView(
title: habit.title,
symbolName: habit.symbolName,
isCompleted: habit.isCompleted,
action: habit.action
)
}
}
}