// // ExportService.swift // Reflect // // Handles exporting mood data to CSV and PDF formats with beautiful visualizations. // import Foundation import SwiftUI import PDFKit import UIKit class ExportService { static let shared = ExportService() // MARK: - Date Formatter private let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .medium formatter.timeStyle = .short return formatter }() private let shortDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "MMM d" return formatter }() private let isoFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime] return formatter }() // MARK: - Colors (using default mood tint colors) private let moodColors: [Mood: UIColor] = [ .great: UIColor(red: 0.29, green: 0.78, blue: 0.55, alpha: 1.0), // Green .good: UIColor(red: 0.56, green: 0.79, blue: 0.35, alpha: 1.0), // Light Green .average: UIColor(red: 1.0, green: 0.76, blue: 0.03, alpha: 1.0), // Yellow .bad: UIColor(red: 1.0, green: 0.58, blue: 0.0, alpha: 1.0), // Orange .horrible: UIColor(red: 0.91, green: 0.30, blue: 0.24, alpha: 1.0) // Red ] private func trackDataExported(format: String, count: Int) { Task { @MainActor in AnalyticsManager.shared.track(.dataExported(format: format, count: count)) } } // MARK: - CSV Export func generateCSV(entries: [MoodEntryModel]) -> String { var csv = "Date,Mood,Mood Value,Notes,Reflection,Weekday,Entry Type,Timestamp\n" let sortedEntries = entries.sorted { $0.forDate > $1.forDate } for entry in sortedEntries { let date = dateFormatter.string(from: entry.forDate) let mood = entry.mood.widgetDisplayName let moodValue = entry.moodValue + 1 // 1-5 scale let notes = escapeCSV(entry.notes ?? "") let reflectionText = entry.reflectionJSON .flatMap { GuidedReflection.decode(from: $0) } .map { $0.responses.filter { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.map { "Q: \($0.question) A: \($0.answer)" }.joined(separator: " | ") } ?? "" let weekday = weekdayName(from: entry.weekDay) let entryType = EntryType(rawValue: entry.entryType)?.description ?? "Unknown" let timestamp = isoFormatter.string(from: entry.timestamp) csv += "\(date),\(mood),\(moodValue),\(notes),\(escapeCSV(reflectionText)),\(weekday),\(entryType),\(timestamp)\n" } return csv } func exportCSV(entries: [MoodEntryModel]) -> URL? { let csv = generateCSV(entries: entries) let filename = "Reflect-Export-\(formattedDate()).csv" let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) do { try csv.write(to: tempURL, atomically: true, encoding: .utf8) trackDataExported(format: "csv", count: entries.count) return tempURL } catch { #if DEBUG print("ExportService: Failed to write CSV: \(error)") #endif return nil } } // MARK: - PDF Export func generatePDF(entries: [MoodEntryModel], title: String = "Mood Report") -> Data? { let sortedEntries = entries.sorted { $0.forDate > $1.forDate } let validEntries = sortedEntries.filter { ![.missing, .placeholder].contains($0.mood) } guard !validEntries.isEmpty else { return nil } // Calculate statistics let stats = calculateStats(entries: validEntries) // PDF Setup let pageWidth: CGFloat = 612 // US Letter let pageHeight: CGFloat = 792 let margin: CGFloat = 40 let contentWidth = pageWidth - (margin * 2) let pdfMetaData = [ kCGPDFContextCreator: "Reflect App", kCGPDFContextTitle: title ] let format = UIGraphicsPDFRendererFormat() format.documentInfo = pdfMetaData as [String: Any] let renderer = UIGraphicsPDFRenderer( bounds: CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight), format: format ) let data = renderer.pdfData { context in // PAGE 1: Overview context.beginPage() var yPosition: CGFloat = margin // Header with gradient background yPosition = drawHeader(at: yPosition, title: title, stats: stats, margin: margin, width: contentWidth, pageWidth: pageWidth, in: context) // Summary Cards yPosition = drawSummaryCards(at: yPosition, stats: stats, margin: margin, width: contentWidth, in: context) // Mood Distribution Chart yPosition = drawMoodDistributionChart(at: yPosition, stats: stats, margin: margin, width: contentWidth, in: context) // Weekday Analysis if yPosition < pageHeight - 250 { yPosition = drawWeekdayAnalysis(at: yPosition, entries: validEntries, margin: margin, width: contentWidth, in: context) } // PAGE 2: Trends & Details context.beginPage() yPosition = margin + 20 // Page 2 Header yPosition = drawSectionTitle("Trends & Patterns", at: yPosition, margin: margin) // Mood Trend Line (last 30 entries) yPosition = drawTrendChart(at: yPosition, entries: validEntries, margin: margin, width: contentWidth, in: context) // Streaks Section yPosition = drawStreaksSection(at: yPosition, stats: stats, margin: margin, width: contentWidth, in: context) // Recent Entries Table yPosition = drawRecentEntries(at: yPosition, entries: validEntries, margin: margin, width: contentWidth, pageHeight: pageHeight, in: context) // Footer drawFooter(pageWidth: pageWidth, pageHeight: pageHeight, margin: margin, in: context) } trackDataExported(format: "pdf", count: entries.count) return data } func exportPDF(entries: [MoodEntryModel], title: String = "Mood Report") -> URL? { guard let data = generatePDF(entries: entries, title: title) else { return nil } let filename = "Reflect-Report-\(formattedDate()).pdf" let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) do { try data.write(to: tempURL) return tempURL } catch { #if DEBUG print("ExportService: Failed to write PDF: \(error)") #endif return nil } } // MARK: - PDF Drawing: Header private func drawHeader(at y: CGFloat, title: String, stats: ExportStats, margin: CGFloat, width: CGFloat, pageWidth: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat { let headerHeight: CGFloat = 120 // Draw gradient background let gradientColors = [ UIColor(red: 0.29, green: 0.56, blue: 0.89, alpha: 1.0).cgColor, UIColor(red: 0.56, green: 0.35, blue: 0.89, alpha: 1.0).cgColor ] let colorSpace = CGColorSpaceCreateDeviceRGB() if let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors as CFArray, locations: [0, 1]) { context.cgContext.saveGState() context.cgContext.addRect(CGRect(x: 0, y: y, width: pageWidth, height: headerHeight)) context.cgContext.clip() context.cgContext.drawLinearGradient(gradient, start: CGPoint(x: 0, y: y), end: CGPoint(x: pageWidth, y: y + headerHeight), options: []) context.cgContext.restoreGState() } // Title let titleFont = UIFont.systemFont(ofSize: 28, weight: .bold) let titleAttributes: [NSAttributedString.Key: Any] = [ .font: titleFont, .foregroundColor: UIColor.white ] let titleString = NSAttributedString(string: title, attributes: titleAttributes) titleString.draw(at: CGPoint(x: margin, y: y + 25)) // Subtitle with date range let subtitleFont = UIFont.systemFont(ofSize: 14, weight: .medium) let subtitleAttributes: [NSAttributedString.Key: Any] = [ .font: subtitleFont, .foregroundColor: UIColor.white.withAlphaComponent(0.9) ] let subtitleString = NSAttributedString(string: stats.dateRange, attributes: subtitleAttributes) subtitleString.draw(at: CGPoint(x: margin, y: y + 60)) // Generated date let dateFont = UIFont.systemFont(ofSize: 11, weight: .regular) let dateAttributes: [NSAttributedString.Key: Any] = [ .font: dateFont, .foregroundColor: UIColor.white.withAlphaComponent(0.7) ] let generatedString = NSAttributedString(string: "Generated \(dateFormatter.string(from: Date()))", attributes: dateAttributes) generatedString.draw(at: CGPoint(x: margin, y: y + 85)) return y + headerHeight + 25 } // MARK: - PDF Drawing: Summary Cards private func drawSummaryCards(at y: CGFloat, stats: ExportStats, margin: CGFloat, width: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat { let cardHeight: CGFloat = 80 let cardWidth = (width - 20) / 3 let cornerRadius: CGFloat = 10 let cards: [(String, String, UIColor)] = [ ("\(stats.totalEntries)", "Total Entries", UIColor(red: 0.29, green: 0.78, blue: 0.55, alpha: 1.0)), (String(format: "%.1f", stats.averageMood), "Avg Mood (1-5)", UIColor(red: 0.29, green: 0.56, blue: 0.89, alpha: 1.0)), (stats.mostCommonMood, "Top Mood", moodColors[Mood.allValues.first { $0.widgetDisplayName == stats.mostCommonMood } ?? .average] ?? .gray) ] for (index, card) in cards.enumerated() { let xPos = margin + CGFloat(index) * (cardWidth + 10) // Card background let cardRect = CGRect(x: xPos, y: y, width: cardWidth, height: cardHeight) let cardPath = UIBezierPath(roundedRect: cardRect, cornerRadius: cornerRadius) // Light background UIColor(white: 0.97, alpha: 1.0).setFill() cardPath.fill() // Accent bar on left let accentRect = CGRect(x: xPos, y: y, width: 4, height: cardHeight) let accentPath = UIBezierPath(roundedRect: accentRect, byRoundingCorners: [.topLeft, .bottomLeft], cornerRadii: CGSize(width: cornerRadius, height: cornerRadius)) card.2.setFill() accentPath.fill() // Value let valueFont = UIFont.systemFont(ofSize: 24, weight: .bold) let valueAttributes: [NSAttributedString.Key: Any] = [ .font: valueFont, .foregroundColor: UIColor.darkGray ] let valueString = NSAttributedString(string: card.0, attributes: valueAttributes) valueString.draw(at: CGPoint(x: xPos + 15, y: y + 18)) // Label let labelFont = UIFont.systemFont(ofSize: 11, weight: .medium) let labelAttributes: [NSAttributedString.Key: Any] = [ .font: labelFont, .foregroundColor: UIColor.gray ] let labelString = NSAttributedString(string: card.1, attributes: labelAttributes) labelString.draw(at: CGPoint(x: xPos + 15, y: y + 50)) } return y + cardHeight + 30 } // MARK: - PDF Drawing: Mood Distribution Chart private func drawMoodDistributionChart(at y: CGFloat, stats: ExportStats, margin: CGFloat, width: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat { let currentY = drawSectionTitle("Mood Distribution", at: y, margin: margin) let chartHeight: CGFloat = 140 let barHeight: CGFloat = 22 let barSpacing: CGFloat = 8 let labelWidth: CGFloat = 70 let percentWidth: CGFloat = 50 let sortedMoods: [Mood] = [.great, .good, .average, .bad, .horrible] for (index, mood) in sortedMoods.enumerated() { let count = stats.moodDistribution[mood.widgetDisplayName] ?? 0 let percentage = stats.totalEntries > 0 ? (Double(count) / Double(stats.totalEntries)) * 100 : 0 let barY = currentY + CGFloat(index) * (barHeight + barSpacing) // Mood label let labelFont = UIFont.systemFont(ofSize: 12, weight: .medium) let labelAttributes: [NSAttributedString.Key: Any] = [ .font: labelFont, .foregroundColor: UIColor.darkGray ] let labelString = NSAttributedString(string: mood.widgetDisplayName, attributes: labelAttributes) labelString.draw(at: CGPoint(x: margin, y: barY + 3)) // Bar background let barX = margin + labelWidth let maxBarWidth = width - labelWidth - percentWidth - 10 let barRect = CGRect(x: barX, y: barY, width: maxBarWidth, height: barHeight) let barPath = UIBezierPath(roundedRect: barRect, cornerRadius: 4) UIColor(white: 0.92, alpha: 1.0).setFill() barPath.fill() // Filled bar let filledWidth = maxBarWidth * CGFloat(percentage / 100) if filledWidth > 0 { let filledRect = CGRect(x: barX, y: barY, width: max(8, filledWidth), height: barHeight) let filledPath = UIBezierPath(roundedRect: filledRect, cornerRadius: 4) (moodColors[mood] ?? .gray).setFill() filledPath.fill() } // Percentage label let percentFont = UIFont.systemFont(ofSize: 12, weight: .semibold) let percentAttributes: [NSAttributedString.Key: Any] = [ .font: percentFont, .foregroundColor: moodColors[mood] ?? .gray ] let percentString = NSAttributedString(string: String(format: "%.0f%%", percentage), attributes: percentAttributes) percentString.draw(at: CGPoint(x: margin + width - percentWidth + 10, y: barY + 3)) } return currentY + chartHeight + 20 } // MARK: - PDF Drawing: Weekday Analysis private func drawWeekdayAnalysis(at y: CGFloat, entries: [MoodEntryModel], margin: CGFloat, width: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat { let currentY = drawSectionTitle("Mood by Day of Week", at: y, margin: margin) let weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] var weekdayAverages: [Double] = Array(repeating: 0, count: 7) var weekdayCounts: [Int] = Array(repeating: 0, count: 7) for entry in entries { let weekdayIndex = entry.weekDay - 1 if weekdayIndex >= 0 && weekdayIndex < 7 { weekdayAverages[weekdayIndex] += Double(entry.moodValue + 1) weekdayCounts[weekdayIndex] += 1 } } for i in 0..<7 { if weekdayCounts[i] > 0 { weekdayAverages[i] /= Double(weekdayCounts[i]) } } // Draw bar chart let chartHeight: CGFloat = 100 let barWidth = (width - 60) / 7 let maxValue: Double = 5.0 for (index, avg) in weekdayAverages.enumerated() { let barX = margin + CGFloat(index) * (barWidth + 5) + 10 let barHeight = chartHeight * CGFloat(avg / maxValue) // Bar if barHeight > 0 { let barRect = CGRect(x: barX, y: currentY + chartHeight - barHeight, width: barWidth - 5, height: barHeight) let barPath = UIBezierPath(roundedRect: barRect, byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: 4, height: 4)) // Color based on average let color: UIColor if avg >= 4.0 { color = moodColors[.great]! } else if avg >= 3.0 { color = moodColors[.good]! } else if avg >= 2.5 { color = moodColors[.average]! } else if avg >= 1.5 { color = moodColors[.bad]! } else { color = moodColors[.horrible]! } color.setFill() barPath.fill() } // Day label let labelFont = UIFont.systemFont(ofSize: 10, weight: .medium) let labelAttributes: [NSAttributedString.Key: Any] = [ .font: labelFont, .foregroundColor: UIColor.gray ] let labelString = NSAttributedString(string: weekdays[index], attributes: labelAttributes) let labelSize = labelString.size() labelString.draw(at: CGPoint(x: barX + (barWidth - 5 - labelSize.width) / 2, y: currentY + chartHeight + 5)) // Value label if weekdayCounts[index] > 0 { let valueFont = UIFont.systemFont(ofSize: 9, weight: .semibold) let valueAttributes: [NSAttributedString.Key: Any] = [ .font: valueFont, .foregroundColor: UIColor.darkGray ] let valueString = NSAttributedString(string: String(format: "%.1f", avg), attributes: valueAttributes) let valueSize = valueString.size() valueString.draw(at: CGPoint(x: barX + (barWidth - 5 - valueSize.width) / 2, y: currentY + chartHeight - barHeight - 15)) } } return currentY + chartHeight + 40 } // MARK: - PDF Drawing: Trend Chart private func drawTrendChart(at y: CGFloat, entries: [MoodEntryModel], margin: CGFloat, width: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat { var currentY = y + 10 let chartHeight: CGFloat = 120 let recentEntries = Array(entries.prefix(30).reversed()) guard recentEntries.count >= 2 else { return currentY } // Section label let labelFont = UIFont.systemFont(ofSize: 12, weight: .medium) let labelAttributes: [NSAttributedString.Key: Any] = [ .font: labelFont, .foregroundColor: UIColor.gray ] let labelString = NSAttributedString(string: "Last \(recentEntries.count) Entries", attributes: labelAttributes) labelString.draw(at: CGPoint(x: margin, y: currentY)) currentY += 25 // Draw chart background let chartRect = CGRect(x: margin, y: currentY, width: width, height: chartHeight) let chartPath = UIBezierPath(roundedRect: chartRect, cornerRadius: 8) UIColor(white: 0.97, alpha: 1.0).setFill() chartPath.fill() // Draw grid lines context.cgContext.setStrokeColor(UIColor(white: 0.9, alpha: 1.0).cgColor) context.cgContext.setLineWidth(1) for i in 1...4 { let gridY = currentY + chartHeight - (chartHeight * CGFloat(i) / 5) context.cgContext.move(to: CGPoint(x: margin + 10, y: gridY)) context.cgContext.addLine(to: CGPoint(x: margin + width - 10, y: gridY)) context.cgContext.strokePath() } // Draw trend line let pointSpacing = (width - 40) / CGFloat(recentEntries.count - 1) var points: [CGPoint] = [] for (index, entry) in recentEntries.enumerated() { let x = margin + 20 + CGFloat(index) * pointSpacing let normalizedMood = CGFloat(entry.moodValue) / 4.0 // 0-4 scale to 0-1 let pointY = currentY + chartHeight - 15 - (normalizedMood * (chartHeight - 30)) points.append(CGPoint(x: x, y: pointY)) } // Draw the line let linePath = UIBezierPath() linePath.move(to: points[0]) for point in points.dropFirst() { linePath.addLine(to: point) } UIColor(red: 0.29, green: 0.56, blue: 0.89, alpha: 1.0).setStroke() linePath.lineWidth = 2.5 linePath.lineCapStyle = .round linePath.lineJoinStyle = .round linePath.stroke() // Draw points for (index, point) in points.enumerated() { let entry = recentEntries[index] let color = moodColors[entry.mood] ?? .gray let pointRect = CGRect(x: point.x - 4, y: point.y - 4, width: 8, height: 8) let pointPath = UIBezierPath(ovalIn: pointRect) color.setFill() pointPath.fill() UIColor.white.setStroke() pointPath.lineWidth = 1.5 pointPath.stroke() } return currentY + chartHeight + 30 } // MARK: - PDF Drawing: Streaks Section private func drawStreaksSection(at y: CGFloat, stats: ExportStats, margin: CGFloat, width: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat { let currentY = drawSectionTitle("Streaks & Consistency", at: y, margin: margin) let cardWidth = (width - 15) / 2 let cardHeight: CGFloat = 60 let streaks: [(String, String, String)] = [ ("\(stats.currentStreak)", "Current Streak", "days in a row"), ("\(stats.longestStreak)", "Longest Streak", "days tracked"), ("\(stats.positiveStreak)", "Best Mood Streak", "good/great days"), (String(format: "%.0f%%", stats.stabilityScore * 100), "Mood Stability", "consistency score") ] for (index, streak) in streaks.enumerated() { let row = index / 2 let col = index % 2 let xPos = margin + CGFloat(col) * (cardWidth + 15) let yPos = currentY + CGFloat(row) * (cardHeight + 10) // Card background let cardRect = CGRect(x: xPos, y: yPos, width: cardWidth, height: cardHeight) let cardPath = UIBezierPath(roundedRect: cardRect, cornerRadius: 8) UIColor(white: 0.97, alpha: 1.0).setFill() cardPath.fill() // Value let valueFont = UIFont.systemFont(ofSize: 22, weight: .bold) let valueAttributes: [NSAttributedString.Key: Any] = [ .font: valueFont, .foregroundColor: UIColor(red: 0.29, green: 0.56, blue: 0.89, alpha: 1.0) ] let valueString = NSAttributedString(string: streak.0, attributes: valueAttributes) valueString.draw(at: CGPoint(x: xPos + 12, y: yPos + 10)) // Label let labelFont = UIFont.systemFont(ofSize: 10, weight: .medium) let labelAttributes: [NSAttributedString.Key: Any] = [ .font: labelFont, .foregroundColor: UIColor.gray ] let labelString = NSAttributedString(string: "\(streak.1) - \(streak.2)", attributes: labelAttributes) labelString.draw(at: CGPoint(x: xPos + 12, y: yPos + 38)) } return currentY + (cardHeight + 10) * 2 + 20 } // MARK: - PDF Drawing: Recent Entries private func drawRecentEntries(at y: CGFloat, entries: [MoodEntryModel], margin: CGFloat, width: CGFloat, pageHeight: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat { var currentY = drawSectionTitle("Recent Entries", at: y, margin: margin) let recentEntries = Array(entries.prefix(15)) let rowHeight: CGFloat = 28 // Header let headerFont = UIFont.systemFont(ofSize: 10, weight: .semibold) let headerAttributes: [NSAttributedString.Key: Any] = [ .font: headerFont, .foregroundColor: UIColor.gray ] NSAttributedString(string: "Date", attributes: headerAttributes).draw(at: CGPoint(x: margin, y: currentY)) NSAttributedString(string: "Mood", attributes: headerAttributes).draw(at: CGPoint(x: margin + 120, y: currentY)) NSAttributedString(string: "Notes", attributes: headerAttributes).draw(at: CGPoint(x: margin + 200, y: currentY)) currentY += 20 // Divider context.cgContext.setStrokeColor(UIColor(white: 0.9, alpha: 1.0).cgColor) context.cgContext.setLineWidth(1) context.cgContext.move(to: CGPoint(x: margin, y: currentY)) context.cgContext.addLine(to: CGPoint(x: margin + width, y: currentY)) context.cgContext.strokePath() currentY += 8 // Entries let bodyFont = UIFont.systemFont(ofSize: 10, weight: .regular) for entry in recentEntries { if currentY > pageHeight - 60 { break } // Date let dateAttributes: [NSAttributedString.Key: Any] = [ .font: bodyFont, .foregroundColor: UIColor.darkGray ] let dateString = NSAttributedString(string: shortDateFormatter.string(from: entry.forDate), attributes: dateAttributes) dateString.draw(at: CGPoint(x: margin, y: currentY + 5)) // Mood indicator dot let dotRect = CGRect(x: margin + 120, y: currentY + 8, width: 10, height: 10) let dotPath = UIBezierPath(ovalIn: dotRect) (moodColors[entry.mood] ?? .gray).setFill() dotPath.fill() // Mood name let moodAttributes: [NSAttributedString.Key: Any] = [ .font: bodyFont, .foregroundColor: moodColors[entry.mood] ?? .gray ] let moodString = NSAttributedString(string: entry.mood.widgetDisplayName, attributes: moodAttributes) moodString.draw(at: CGPoint(x: margin + 135, y: currentY + 5)) // Notes (truncated) if let notes = entry.notes, !notes.isEmpty { let truncatedNotes = notes.count > 40 ? String(notes.prefix(40)) + "..." : notes let notesAttributes: [NSAttributedString.Key: Any] = [ .font: bodyFont, .foregroundColor: UIColor.gray ] let notesString = NSAttributedString(string: truncatedNotes, attributes: notesAttributes) notesString.draw(at: CGPoint(x: margin + 200, y: currentY + 5)) } currentY += rowHeight } return currentY + 20 } // MARK: - PDF Drawing: Footer private func drawFooter(pageWidth: CGFloat, pageHeight: CGFloat, margin: CGFloat, in context: UIGraphicsPDFRendererContext) { let footerY = pageHeight - 30 let footerFont = UIFont.systemFont(ofSize: 9, weight: .regular) let footerAttributes: [NSAttributedString.Key: Any] = [ .font: footerFont, .foregroundColor: UIColor.lightGray ] let footerString = NSAttributedString(string: "Generated by Reflect - Your Mood Tracking Companion", attributes: footerAttributes) let footerSize = footerString.size() footerString.draw(at: CGPoint(x: (pageWidth - footerSize.width) / 2, y: footerY)) } // MARK: - PDF Drawing: Section Title private func drawSectionTitle(_ title: String, at y: CGFloat, margin: CGFloat) -> CGFloat { let font = UIFont.systemFont(ofSize: 16, weight: .bold) let attributes: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: UIColor.darkGray ] let string = NSAttributedString(string: title, attributes: attributes) string.draw(at: CGPoint(x: margin, y: y)) return y + 30 } // MARK: - Statistics struct ExportStats { let totalEntries: Int let dateRange: String let averageMood: Double let mostCommonMood: String let moodDistribution: [String: Int] let currentStreak: Int let longestStreak: Int let positiveStreak: Int let stabilityScore: Double } private func calculateStats(entries: [MoodEntryModel]) -> ExportStats { let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) } var moodCounts: [String: Int] = [:] var totalMoodValue = 0 for entry in validEntries { let moodName = entry.mood.widgetDisplayName moodCounts[moodName, default: 0] += 1 totalMoodValue += entry.moodValue + 1 } let avgMood = validEntries.isEmpty ? 0 : Double(totalMoodValue) / Double(validEntries.count) let mostCommon = moodCounts.max(by: { $0.value < $1.value })?.key ?? "N/A" var dateRange = "No entries" if let first = validEntries.last?.forDate, let last = validEntries.first?.forDate { dateRange = "\(shortDateFormatter.string(from: first)) - \(shortDateFormatter.string(from: last))" } // Calculate streaks let calendar = Calendar.current let sortedByDateDesc = validEntries.sorted { $0.forDate > $1.forDate } var currentStreak = 0 var longestStreak = 1 var tempStreak = 1 if let mostRecent = sortedByDateDesc.first?.forDate { let today = calendar.startOfDay(for: Date()) let yesterday = calendar.date(byAdding: .day, value: -1, to: today)! if calendar.isDate(mostRecent, inSameDayAs: today) || calendar.isDate(mostRecent, inSameDayAs: yesterday) { currentStreak = 1 for i in 1..= 2 { swings += 1 } } let stabilityScore = sortedByDateAsc.count > 1 ? 1.0 - min(Double(swings) / Double(sortedByDateAsc.count - 1), 1.0) : 1.0 return ExportStats( totalEntries: validEntries.count, dateRange: dateRange, averageMood: avgMood, mostCommonMood: mostCommon, moodDistribution: moodCounts, currentStreak: currentStreak, longestStreak: longestStreak, positiveStreak: positiveStreak, stabilityScore: stabilityScore ) } // MARK: - Helpers private func escapeCSV(_ string: String) -> String { var result = string if result.contains("\"") || result.contains(",") || result.contains("\n") { result = result.replacingOccurrences(of: "\"", with: "\"\"") result = "\"\(result)\"" } return result } private func weekdayName(from weekday: Int) -> String { let weekdays = ["", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] return weekdays[safe: weekday] ?? "Unknown" } private func formattedDate() -> String { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" return formatter.string(from: Date()) } } // MARK: - EntryType Description extension EntryType: CustomStringConvertible { public var description: String { switch self { case .listView: return "Manual" case .widget: return "Widget" case .watch: return "Watch" case .shortcut: return "Shortcut" case .filledInMissing: return "Auto-filled" case .notification: return "Notification" case .header: return "Header" case .siri: return "Siri" case .controlCenter: return "Control Center" case .liveActivity: return "Live Activity" } } } // MARK: - Safe Array Access private extension Array { subscript(safe index: Int) -> Element? { return indices.contains(index) ? self[index] : nil } }