Files
Reflect/Shared/Services/MoodDataSummarizer.swift
Trey t 70400b7790 Optimize AI generation speed and add richer insight data
Speed optimizations:
- Add session.prewarm() in InsightsViewModel and ReportsViewModel init
  for 40% faster first-token latency
- Cap maximumResponseTokens on all 8 AI respond() calls (100-600 per use case)
- Add prompt brevity constraints ("1-2 sentences", "2 sentences")
- Reduce report batch concurrency from 4 to 2 to prevent device contention
- Pre-fetch health data once and share across all 3 insight periods

Richer insight data in MoodDataSummarizer:
- Tag-mood correlations: overall frequency + good day vs bad day tag breakdown
- Weather-mood correlations: avg mood by condition and temperature range
- Absence pattern detection: logging gap count with pre/post-gap mood averages
- Entry source breakdown: % of entries from App, Widget, Watch, Siri, etc.
- Update insight prompt to leverage tags, weather, and gap data when available

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:52:14 -05:00

695 lines
26 KiB
Swift

//
// MoodDataSummarizer.swift
// Reflect
//
// Created by Claude Code on 12/13/24.
//
import Foundation
/// Summary of mood data for a specific time period, formatted for AI consumption
struct MoodDataSummary {
let periodName: String
let totalEntries: Int
let dateRange: String
// Mood distribution
let moodCounts: [String: Int]
let moodPercentages: [String: Int]
let averageMoodScore: Double
// Temporal patterns
let weekdayAverages: [String: Double]
let weekendAverage: Double
let weekdayAverage: Double
let bestDayOfWeek: String
let worstDayOfWeek: String
// Trends
let recentTrend: String
let trendMagnitude: Double
// Streaks
let currentLoggingStreak: Int
let longestLoggingStreak: Int
let longestPositiveStreak: Int
let longestNegativeStreak: Int
// Variability
let moodSwingCount: Int
let moodStabilityScore: Double
// Recent context
let last7DaysAverage: Double
let last7DaysMoods: [String]
// Notable observations
let hasAllMoodTypes: Bool
let missingMoodTypes: [String]
// Health data for AI analysis (optional)
let healthAverages: HealthService.HealthAverages?
// Tag-mood correlations
let tagFrequencies: [String: Int]
let goodDayTags: [String: Int] // tag counts for entries with mood good/great
let badDayTags: [String: Int] // tag counts for entries with mood bad/horrible
// Weather-mood correlation
let weatherMoodAverages: [String: Double] // condition -> avg mood (1-5 scale)
let tempRangeMoodAverages: [String: Double] // "Cold"/"Mild"/"Warm"/"Hot" -> avg mood
// Absence patterns
let loggingGapCount: Int // number of 2+ day gaps
let preGapMoodAverage: Double // avg mood in 3 days before a gap
let postGapMoodAverage: Double // avg mood in 3 days after returning
// Entry source breakdown
let entrySourceBreakdown: [String: Int] // source name -> count
}
/// Transforms raw MoodEntryModel data into AI-optimized summaries
class MoodDataSummarizer {
private let calendar = Calendar.current
private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter
}()
// MARK: - Main Summarization
func summarize(entries: [MoodEntryModel], periodName: String, healthAverages: HealthService.HealthAverages? = nil) -> MoodDataSummary {
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
guard !validEntries.isEmpty else {
return emptyDataSummary(periodName: periodName)
}
let sortedEntries = validEntries.sorted { $0.forDate < $1.forDate }
// Calculate all metrics
let moodDistribution = calculateMoodDistribution(entries: validEntries)
let temporalPatterns = calculateTemporalPatterns(entries: validEntries)
let trend = calculateTrend(entries: sortedEntries)
let streaks = calculateStreaks(entries: sortedEntries)
let variability = calculateVariability(entries: sortedEntries)
let recentContext = calculateRecentContext(entries: sortedEntries)
let moodTypes = calculateMoodTypes(entries: validEntries)
// Format date range
let dateRange = formatDateRange(entries: sortedEntries)
let tagAnalysis = calculateTagAnalysis(entries: validEntries)
let weatherAnalysis = calculateWeatherAnalysis(entries: validEntries)
let absencePatterns = calculateAbsencePatterns(entries: sortedEntries)
let sourceBreakdown = calculateEntrySourceBreakdown(entries: validEntries)
return MoodDataSummary(
periodName: periodName,
totalEntries: validEntries.count,
dateRange: dateRange,
moodCounts: moodDistribution.counts,
moodPercentages: moodDistribution.percentages,
averageMoodScore: moodDistribution.average,
weekdayAverages: temporalPatterns.weekdayAverages,
weekendAverage: temporalPatterns.weekendAverage,
weekdayAverage: temporalPatterns.weekdayAverage,
bestDayOfWeek: temporalPatterns.bestDay,
worstDayOfWeek: temporalPatterns.worstDay,
recentTrend: trend.direction,
trendMagnitude: trend.magnitude,
currentLoggingStreak: streaks.current,
longestLoggingStreak: streaks.longest,
longestPositiveStreak: streaks.longestPositive,
longestNegativeStreak: streaks.longestNegative,
moodSwingCount: variability.swingCount,
moodStabilityScore: variability.stabilityScore,
last7DaysAverage: recentContext.average,
last7DaysMoods: recentContext.moods,
hasAllMoodTypes: moodTypes.hasAll,
missingMoodTypes: moodTypes.missing,
healthAverages: healthAverages,
tagFrequencies: tagAnalysis.frequencies,
goodDayTags: tagAnalysis.goodDayTags,
badDayTags: tagAnalysis.badDayTags,
weatherMoodAverages: weatherAnalysis.conditionAverages,
tempRangeMoodAverages: weatherAnalysis.tempRangeAverages,
loggingGapCount: absencePatterns.gapCount,
preGapMoodAverage: absencePatterns.preGapAverage,
postGapMoodAverage: absencePatterns.postGapAverage,
entrySourceBreakdown: sourceBreakdown
)
}
// MARK: - Mood Distribution
private func calculateMoodDistribution(entries: [MoodEntryModel]) -> (counts: [String: Int], percentages: [String: Int], average: Double) {
var counts: [String: Int] = [:]
var totalScore = 0
for entry in entries {
let moodName = entry.mood.widgetDisplayName.lowercased()
counts[moodName, default: 0] += 1
// Use 1-5 scale (add 1 to raw 0-4 values) for human-readable averages
totalScore += Int(entry.moodValue) + 1
}
var percentages: [String: Int] = [:]
for (mood, count) in counts {
percentages[mood] = Int((Double(count) / Double(entries.count)) * 100)
}
let average = Double(totalScore) / Double(entries.count)
return (counts, percentages, average)
}
// MARK: - Temporal Patterns
private func calculateTemporalPatterns(entries: [MoodEntryModel]) -> (weekdayAverages: [String: Double], weekendAverage: Double, weekdayAverage: Double, bestDay: String, worstDay: String) {
let weekdayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
var weekdayTotals: [Int: (total: Int, count: Int)] = [:]
for entry in entries {
let weekday = Int(entry.weekDay)
let current = weekdayTotals[weekday, default: (0, 0)]
// Use 1-5 scale (add 1 to raw 0-4 values)
weekdayTotals[weekday] = (current.total + Int(entry.moodValue) + 1, current.count + 1)
}
var weekdayAverages: [String: Double] = [:]
var bestDay = "Monday"
var worstDay = "Monday"
var bestAvg = -1.0
var worstAvg = 6.0
for (weekday, data) in weekdayTotals {
let avg = Double(data.total) / Double(data.count)
let dayName = weekdayNames[weekday - 1]
weekdayAverages[dayName] = avg
if avg > bestAvg {
bestAvg = avg
bestDay = dayName
}
if avg < worstAvg {
worstAvg = avg
worstDay = dayName
}
}
// Weekend vs weekday (use 1-5 scale)
let weekendEntries = entries.filter { [1, 7].contains(Int($0.weekDay)) }
let weekdayEntries = entries.filter { ![1, 7].contains(Int($0.weekDay)) }
let weekendAvg = weekendEntries.isEmpty ? 0 : Double(weekendEntries.reduce(0) { $0 + Int($1.moodValue) + 1 }) / Double(weekendEntries.count)
let weekdayAvg = weekdayEntries.isEmpty ? 0 : Double(weekdayEntries.reduce(0) { $0 + Int($1.moodValue) + 1 }) / Double(weekdayEntries.count)
return (weekdayAverages, weekendAvg, weekdayAvg, bestDay, worstDay)
}
// MARK: - Trend Analysis
private func calculateTrend(entries: [MoodEntryModel]) -> (direction: String, magnitude: Double) {
guard entries.count >= 4 else {
return ("stable", 0.0)
}
let halfCount = entries.count / 2
let firstHalf = Array(entries.prefix(halfCount))
let secondHalf = Array(entries.suffix(halfCount))
// Use 1-5 scale
let firstAvg = Double(firstHalf.reduce(0) { $0 + Int($1.moodValue) + 1 }) / Double(firstHalf.count)
let secondAvg = Double(secondHalf.reduce(0) { $0 + Int($1.moodValue) + 1 }) / Double(secondHalf.count)
let diff = secondAvg - firstAvg
let direction: String
if diff > 0.5 {
direction = "improving"
} else if diff < -0.5 {
direction = "declining"
} else {
direction = "stable"
}
return (direction, abs(diff))
}
// MARK: - Streak Calculations
private func calculateStreaks(entries: [MoodEntryModel]) -> (current: Int, longest: Int, longestPositive: Int, longestNegative: Int) {
let sortedByDateDesc = entries.sorted { $0.forDate > $1.forDate }
// Current logging streak
var currentStreak = 0
let today = calendar.startOfDay(for: Date())
if let mostRecent = sortedByDateDesc.first?.forDate {
let yesterday = calendar.date(byAdding: .day, value: -1, to: today)!
if calendar.isDate(mostRecent, inSameDayAs: today) || calendar.isDate(mostRecent, inSameDayAs: yesterday) {
currentStreak = 1
var checkDate = calendar.date(byAdding: .day, value: -1, to: mostRecent)!
for entry in sortedByDateDesc.dropFirst() {
if calendar.isDate(entry.forDate, inSameDayAs: checkDate) {
currentStreak += 1
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate)!
} else {
break
}
}
}
}
// Longest logging streak
var longestStreak = 1
var tempStreak = 1
let sortedByDateAsc = entries.sorted { $0.forDate < $1.forDate }
for i in 1..<sortedByDateAsc.count {
let dayDiff = calendar.dateComponents([.day], from: sortedByDateAsc[i-1].forDate, to: sortedByDateAsc[i].forDate).day ?? 0
if dayDiff == 1 {
tempStreak += 1
longestStreak = max(longestStreak, tempStreak)
} else {
tempStreak = 1
}
}
// Positive mood streak (good/great)
let longestPositive = calculateMoodStreak(entries: sortedByDateAsc, moods: [.good, .great])
// Negative mood streak (bad/horrible)
let longestNegative = calculateMoodStreak(entries: sortedByDateAsc, moods: [.bad, .horrible])
return (currentStreak, longestStreak, longestPositive, longestNegative)
}
private func calculateMoodStreak(entries: [MoodEntryModel], moods: [Mood]) -> Int {
guard !entries.isEmpty else { return 0 }
// Sort by date to ensure proper ordering
let sortedEntries = entries.sorted { $0.forDate < $1.forDate }
var longest = 0
var current = 0
var previousDate: Date?
for entry in sortedEntries {
let entryDate = calendar.startOfDay(for: entry.forDate)
// Check if this is a consecutive calendar day from the previous entry
let isConsecutive: Bool
if let prevDate = previousDate {
let dayDiff = calendar.dateComponents([.day], from: prevDate, to: entryDate).day ?? 0
isConsecutive = dayDiff == 1
} else {
isConsecutive = true // First entry starts a potential streak
}
if moods.contains(entry.mood) {
if isConsecutive || previousDate == nil {
current += 1
} else {
current = 1 // Reset to 1 (this entry starts new streak)
}
longest = max(longest, current)
} else {
current = 0
}
previousDate = entryDate
}
return longest
}
// MARK: - Variability
private func calculateVariability(entries: [MoodEntryModel]) -> (swingCount: Int, stabilityScore: Double) {
guard entries.count >= 2 else {
return (0, 1.0)
}
var swings = 0
for i in 1..<entries.count {
let diff = abs(Int(entries[i].moodValue) - Int(entries[i-1].moodValue))
if diff >= 2 {
swings += 1
}
}
let swingRate = Double(swings) / Double(entries.count - 1)
let stabilityScore = 1.0 - min(swingRate, 1.0)
return (swings, stabilityScore)
}
// MARK: - Recent Context
private func calculateRecentContext(entries: [MoodEntryModel]) -> (average: Double, moods: [String]) {
let recentEntries = entries.suffix(7)
guard !recentEntries.isEmpty else {
return (0, [])
}
// Use 1-5 scale
let average = Double(recentEntries.reduce(0) { $0 + Int($1.moodValue) + 1 }) / Double(recentEntries.count)
let moods = recentEntries.map { $0.mood.widgetDisplayName }
return (average, moods)
}
// MARK: - Mood Types
private func calculateMoodTypes(entries: [MoodEntryModel]) -> (hasAll: Bool, missing: [String]) {
let allMoods: Set<Mood> = [.great, .good, .average, .bad, .horrible]
let presentMoods = Set(entries.map { $0.mood })
let missing = allMoods.subtracting(presentMoods).map { $0.widgetDisplayName }
let hasAll = missing.isEmpty
return (hasAll, missing)
}
// MARK: - Tag Analysis
private func calculateTagAnalysis(entries: [MoodEntryModel]) -> (frequencies: [String: Int], goodDayTags: [String: Int], badDayTags: [String: Int]) {
var frequencies: [String: Int] = [:]
var goodDayTags: [String: Int] = [:]
var badDayTags: [String: Int] = [:]
for entry in entries {
let entryTags = entry.tags
guard !entryTags.isEmpty else { continue }
for tag in entryTags {
let normalizedTag = tag.lowercased()
frequencies[normalizedTag, default: 0] += 1
if [.good, .great].contains(entry.mood) {
goodDayTags[normalizedTag, default: 0] += 1
} else if [.bad, .horrible].contains(entry.mood) {
badDayTags[normalizedTag, default: 0] += 1
}
}
}
return (frequencies, goodDayTags, badDayTags)
}
// MARK: - Weather Analysis
private func calculateWeatherAnalysis(entries: [MoodEntryModel]) -> (conditionAverages: [String: Double], tempRangeAverages: [String: Double]) {
var conditionTotals: [String: (total: Int, count: Int)] = [:]
var tempRangeTotals: [String: (total: Int, count: Int)] = [:]
for entry in entries {
guard let json = entry.weatherJSON, let weather = WeatherData.decode(from: json) else { continue }
let moodScore = Int(entry.moodValue) + 1 // 1-5 scale
// Group by weather condition
let condition = weather.condition
let current = conditionTotals[condition, default: (0, 0)]
conditionTotals[condition] = (current.total + moodScore, current.count + 1)
// Group by temperature range (convert Celsius to Fahrenheit)
let tempF = weather.temperature * 9.0 / 5.0 + 32.0
let tempRange: String
if tempF < 50 {
tempRange = "Cold"
} else if tempF <= 70 {
tempRange = "Mild"
} else if tempF <= 85 {
tempRange = "Warm"
} else {
tempRange = "Hot"
}
let currentTemp = tempRangeTotals[tempRange, default: (0, 0)]
tempRangeTotals[tempRange] = (currentTemp.total + moodScore, currentTemp.count + 1)
}
var conditionAverages: [String: Double] = [:]
for (condition, data) in conditionTotals {
conditionAverages[condition] = Double(data.total) / Double(data.count)
}
var tempRangeAverages: [String: Double] = [:]
for (range, data) in tempRangeTotals {
tempRangeAverages[range] = Double(data.total) / Double(data.count)
}
return (conditionAverages, tempRangeAverages)
}
// MARK: - Absence Patterns
private func calculateAbsencePatterns(entries: [MoodEntryModel]) -> (gapCount: Int, preGapAverage: Double, postGapAverage: Double) {
guard entries.count >= 2 else {
return (0, 0, 0)
}
var gapCount = 0
var preGapScores: [Int] = []
var postGapScores: [Int] = []
for i in 1..<entries.count {
let dayDiff = calendar.dateComponents([.day], from: entries[i-1].forDate, to: entries[i].forDate).day ?? 0
guard dayDiff >= 2 else { continue }
gapCount += 1
// Collect up to 3 entries before the gap
let preStart = max(0, i - 3)
for j in preStart..<i {
preGapScores.append(Int(entries[j].moodValue) + 1)
}
// Collect up to 3 entries after the gap
let postEnd = min(entries.count, i + 3)
for j in i..<postEnd {
postGapScores.append(Int(entries[j].moodValue) + 1)
}
}
let preAvg = preGapScores.isEmpty ? 0.0 : Double(preGapScores.reduce(0, +)) / Double(preGapScores.count)
let postAvg = postGapScores.isEmpty ? 0.0 : Double(postGapScores.reduce(0, +)) / Double(postGapScores.count)
return (gapCount, preAvg, postAvg)
}
// MARK: - Entry Source Breakdown
private func calculateEntrySourceBreakdown(entries: [MoodEntryModel]) -> [String: Int] {
var breakdown: [String: Int] = [:]
let sourceNames: [Int: String] = [
0: "App",
1: "Widget",
2: "Watch",
3: "Shortcut",
4: "Auto-fill",
5: "Notification",
6: "Header",
7: "Siri",
8: "Control Center",
9: "Live Activity"
]
for entry in entries {
let name = sourceNames[entry.entryType] ?? "Other"
breakdown[name, default: 0] += 1
}
return breakdown
}
// MARK: - Helpers
private func formatDateRange(entries: [MoodEntryModel]) -> String {
guard let first = entries.first, let last = entries.last else {
return "No data"
}
let startDate = dateFormatter.string(from: first.forDate)
let endDate = dateFormatter.string(from: last.forDate)
return "\(startDate) - \(endDate)"
}
private func emptyDataSummary(periodName: String) -> MoodDataSummary {
MoodDataSummary(
periodName: periodName,
totalEntries: 0,
dateRange: "No data",
moodCounts: [:],
moodPercentages: [:],
averageMoodScore: 0,
weekdayAverages: [:],
weekendAverage: 0,
weekdayAverage: 0,
bestDayOfWeek: "N/A",
worstDayOfWeek: "N/A",
recentTrend: "stable",
trendMagnitude: 0,
currentLoggingStreak: 0,
longestLoggingStreak: 0,
longestPositiveStreak: 0,
longestNegativeStreak: 0,
moodSwingCount: 0,
moodStabilityScore: 1.0,
last7DaysAverage: 0,
last7DaysMoods: [],
hasAllMoodTypes: false,
missingMoodTypes: ["great", "good", "average", "bad", "horrible"],
healthAverages: nil,
tagFrequencies: [:],
goodDayTags: [:],
badDayTags: [:],
weatherMoodAverages: [:],
tempRangeMoodAverages: [:],
loggingGapCount: 0,
preGapMoodAverage: 0,
postGapMoodAverage: 0,
entrySourceBreakdown: [:]
)
}
// MARK: - Prompt String Generation
/// Generates a concise prompt string optimized for Apple's 4096 token context limit
func toPromptString(_ summary: MoodDataSummary) -> String {
// Compact format to stay under token limit
var lines: [String] = []
lines.append("Period: \(summary.periodName), \(summary.totalEntries) entries, avg \(String(format: "%.1f", summary.averageMoodScore))/5")
// Mood distribution - compact
let moodDist = summary.moodPercentages.sorted { $0.key < $1.key }
.map { "\($0.key): \($0.value)%" }
.joined(separator: ", ")
lines.append("Moods: \(moodDist)")
// Day patterns - only best/worst
lines.append("Best day: \(summary.bestDayOfWeek), Worst: \(summary.worstDayOfWeek)")
lines.append("Weekend avg: \(String(format: "%.1f", summary.weekendAverage)), Weekday avg: \(String(format: "%.1f", summary.weekdayAverage))")
// Trends
lines.append("Trend: \(summary.recentTrend), Last 7 days avg: \(String(format: "%.1f", summary.last7DaysAverage))")
// Streaks - compact
lines.append("Streaks - Current: \(summary.currentLoggingStreak)d, Longest: \(summary.longestLoggingStreak)d, Best positive: \(summary.longestPositiveStreak)d, Worst negative: \(summary.longestNegativeStreak)d")
// Stability
lines.append("Stability: \(String(format: "%.0f", summary.moodStabilityScore * 100))%, Mood swings: \(summary.moodSwingCount)")
// Health data for AI analysis (if available)
if let health = summary.healthAverages, health.hasData {
lines.append("")
lines.append("Apple Health data (\(health.daysWithHealthData) days with data):")
// Activity metrics
var activityMetrics: [String] = []
if let steps = health.avgSteps {
activityMetrics.append("Steps: \(steps.formatted())/day")
}
if let exercise = health.avgExerciseMinutes {
activityMetrics.append("Exercise: \(exercise) min/day")
}
if let calories = health.avgActiveCalories {
activityMetrics.append("Active cal: \(calories)/day")
}
if let distance = health.avgDistanceKm {
activityMetrics.append("Distance: \(String(format: "%.1f", distance)) km/day")
}
if !activityMetrics.isEmpty {
lines.append("Activity: \(activityMetrics.joined(separator: ", "))")
}
// Heart metrics
var heartMetrics: [String] = []
if let hr = health.avgHeartRate {
heartMetrics.append("Avg HR: \(Int(hr)) bpm")
}
if let restingHR = health.avgRestingHeartRate {
heartMetrics.append("Resting HR: \(Int(restingHR)) bpm")
}
if let hrv = health.avgHRV {
heartMetrics.append("HRV: \(Int(hrv)) ms")
}
if !heartMetrics.isEmpty {
lines.append("Heart: \(heartMetrics.joined(separator: ", "))")
}
// Recovery metrics
var recoveryMetrics: [String] = []
if let sleep = health.avgSleepHours {
recoveryMetrics.append("Sleep: \(String(format: "%.1f", sleep)) hrs/night")
}
if let mindful = health.avgMindfulMinutes {
recoveryMetrics.append("Mindfulness: \(mindful) min/day")
}
if !recoveryMetrics.isEmpty {
lines.append("Recovery: \(recoveryMetrics.joined(separator: ", "))")
}
lines.append("Analyze how these health metrics may correlate with mood patterns.")
}
// Tag-mood correlations (only if tags exist)
if !summary.tagFrequencies.isEmpty {
let topTags = summary.tagFrequencies.sorted { $0.value > $1.value }.prefix(8)
.map { "\($0.key)(\($0.value))" }.joined(separator: ", ")
lines.append("Themes: \(topTags)")
if !summary.goodDayTags.isEmpty {
let goodTags = summary.goodDayTags.sorted { $0.value > $1.value }.prefix(5)
.map { "\($0.key)(\($0.value))" }.joined(separator: ", ")
lines.append("Good day themes: \(goodTags)")
}
if !summary.badDayTags.isEmpty {
let badTags = summary.badDayTags.sorted { $0.value > $1.value }.prefix(5)
.map { "\($0.key)(\($0.value))" }.joined(separator: ", ")
lines.append("Bad day themes: \(badTags)")
}
}
// Weather-mood (only if weather data exists)
if !summary.weatherMoodAverages.isEmpty {
let weatherMood = summary.weatherMoodAverages.sorted { $0.value > $1.value }
.map { "\($0.key) avg \(String(format: "%.1f", $0.value))" }.joined(separator: ", ")
lines.append("Weather-mood: \(weatherMood)")
}
if !summary.tempRangeMoodAverages.isEmpty {
let tempMood = ["Cold", "Mild", "Warm", "Hot"].compactMap { range -> String? in
guard let avg = summary.tempRangeMoodAverages[range] else { return nil }
return "\(range) avg \(String(format: "%.1f", avg))"
}.joined(separator: ", ")
if !tempMood.isEmpty {
lines.append("Temp-mood: \(tempMood)")
}
}
// Gaps (only if gaps exist)
if summary.loggingGapCount > 0 {
lines.append("Logging gaps: \(summary.loggingGapCount) breaks of 2+ days. Pre-gap avg: \(String(format: "%.1f", summary.preGapMoodAverage))/5, Post-return avg: \(String(format: "%.1f", summary.postGapMoodAverage))/5")
}
// Sources (only if multiple sources)
if summary.entrySourceBreakdown.count > 1 {
let total = Double(summary.entrySourceBreakdown.values.reduce(0, +))
let sources = summary.entrySourceBreakdown.sorted { $0.value > $1.value }
.map { "\($0.key) \(Int(Double($0.value) / total * 100))%" }.joined(separator: ", ")
lines.append("Entry sources: \(sources)")
}
return lines.joined(separator: "\n")
}
}