
Usage Insights
- 2 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates on-device user-facing usage statistics, activity summaries, and personalized insight dashboards like weekly recaps and year-in-review.
About
Generates an on-device usage insights system that records activity events with SwiftData, computes personalized insights like streaks and top categories, and displays them in a dashboard with shareable recap screens. A developer uses it to show usage stats to the user, not to a backend.
- SwiftData events plus insight cards and trend indicators
- Shareable Wrapped-style recap screens
Usage Insights by the numbers
- 2 all-time installs (skills.sh)
- Ranked #888 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill usage-insightsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates on-device user-facing usage statistics, activity summaries, and personalized insight dashboards like weekly recaps and year-in-review.
Files
Usage Insights Generator
Generate a production usage insights system that records user activity events with SwiftData, computes personalized insights (streaks, most active day, top categories), and displays them in a dashboard with insight cards, period pickers, trend indicators, and optional shareable recap screens.
When This Skill Activates
Use this skill when the user:
- Asks to "show usage statistics" or "add usage stats"
- Wants "user insights" or "activity insights"
- Mentions "activity summary" or "weekly summary"
- Asks about a "usage dashboard" or "insights dashboard"
- Wants a "weekly recap" or "monthly recap"
- Mentions "year in review" or "year-in-review"
- Asks for "Spotify Wrapped style" or "Wrapped-style recap"
- Wants to "show the user their activity" or "personalized stats"
Pre-Generation Checks
1. Project Context Detection
- [ ] Check Swift version (requires Swift 5.9+)
- [ ] Check deployment target (iOS 17+ / macOS 14+ for @Observable and SwiftData)
- [ ] Identify source file locations
- [ ] Check for Swift Charts availability (iOS 16+ / macOS 13+, but recommend iOS 17+)
2. Conflict Detection
Search for existing usage tracking or insights code:
Glob: **/*UsageEvent*.swift, **/*Insight*.swift, **/*Recap*.swift, **/*ActivityLog*.swift
Grep: "UsageEvent" or "InsightCalculator" or "activitySummary" or "SwiftData" and "event"If existing analytics/tracking found:
- Ask if user wants to build insights on top of existing event data
- If yes, adapt
InsightCalculatorto work with existing models - If no, generate fresh event recording alongside existing code
3. Data Layer Detection
Check for SwiftData usage:
Grep: "import SwiftData" or "@Model" or "ModelContainer"If SwiftData already in use:
- Integrate
UsageEventinto existingModelContainer - Use existing schema migration strategy
If no SwiftData:
- Generate full setup including
ModelContainerconfiguration
Configuration Questions
Ask user via AskUserQuestion:
1. Insight period?
- Daily (today's activity breakdown)
- Weekly (7-day recap with day-by-day comparison) — recommended
- Monthly (30-day trends with weekly rollups)
- Yearly (year-in-review with monthly highlights)
- All of the above (period picker lets user switch)
2. Visualization style?
- Cards only (simple stat cards with trend indicators)
- Charts only (Swift Charts bar/line graphs)
- Both cards and charts — recommended
3. Include shareable recap card?
- Yes (generates a recap view that can be rendered to an image and shared)
- No (dashboard only, no sharing)
4. Data source?
- SwiftData (generate
UsageEventmodel and recorder) — recommended - Custom (user provides their own event data; generate calculator and views only)
Generation Process
Step 1: Read Templates
Read templates.md for production Swift code.
Step 2: Create Core Data Files
Generate these files: 1. UsageEvent.swift — SwiftData @Model for recording user activity events 2. InsightResult.swift — Model for computed insights (title, value, trend, visualization type)
Step 3: Create Calculation Engine
3. InsightCalculator.swift — Pure functions that aggregate events into insights
Step 4: Create UI Files
4. InsightsDashboardView.swift — Main dashboard with grid of insight cards and period picker 5. InsightCardView.swift — Individual insight card with icon, value, trend indicator, sparkline
Step 5: Create Optional Files
Based on configuration:
UsageRecapView.swift— If shareable recap selected (paged summary with share card generation)UsageEventRecorder.swift— If SwiftData data source selected (convenience class for recording events)
Step 6: Determine File Location
Check project structure:
- If
Sources/exists ->Sources/UsageInsights/ - If
App/exists ->App/UsageInsights/ - Otherwise ->
UsageInsights/
Output Format
After generation, provide:
Files Created
UsageInsights/
├── UsageEvent.swift # SwiftData @Model for activity events
├── InsightResult.swift # Computed insight model
├── InsightCalculator.swift # Aggregation engine
├── InsightsDashboardView.swift # Dashboard with period picker
├── InsightCardView.swift # Individual insight card
├── UsageRecapView.swift # Shareable recap (optional)
└── UsageEventRecorder.swift # Event recording helper (optional)Integration Steps
Add ModelContainer (if not already present):
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [UsageEvent.self])
}
}Record events from anywhere in the app:
struct TaskDetailView: View {
@Environment(\.modelContext) private var modelContext
@State private var recorder: UsageEventRecorder?
var body: some View {
Button("Complete Task") {
completeTask()
recorder?.record(
.taskCompleted,
metadata: ["category": "work", "priority": "high"]
)
}
.onAppear {
recorder = UsageEventRecorder(modelContext: modelContext)
}
}
}Show the insights dashboard:
NavigationLink("My Insights") {
InsightsDashboardView()
}Show a weekly recap:
struct WeeklyRecapSheet: View {
@Environment(\.modelContext) private var modelContext
@State private var showRecap = false
var body: some View {
Button("View Weekly Recap") {
showRecap = true
}
.sheet(isPresented: $showRecap) {
UsageRecapView(period: .week)
}
}
}Record events with duration tracking:
// Start a timed session
let sessionStart = Date()
// ... user does work ...
// Record when session ends
recorder?.record(
.sessionCompleted,
metadata: ["screen": "editor"],
duration: Date().timeIntervalSince(sessionStart)
)Testing
@Test
func calculatesWeeklySummaryCorrectly() async throws {
let calendar = Calendar.current
let now = Date()
let events: [UsageEvent] = (0..<7).flatMap { dayOffset in
let date = calendar.date(byAdding: .day, value: -dayOffset, to: now)!
return (0..<(dayOffset == 2 ? 5 : 2)).map { _ in
UsageEvent(eventType: "taskCompleted", timestamp: date)
}
}
let calculator = InsightCalculator()
let insights = calculator.weeklySummary(from: events, referenceDate: now)
let totalInsight = insights.first { $0.title == "Total Events" }
#expect(totalInsight != nil)
#expect(totalInsight?.value == "19") // 5 + (6 * 2)
}
@Test
func identifiesMostActiveDay() async throws {
let calendar = Calendar.current
let now = Date()
// Create 5 events on Wednesday, 2 on other days
var events: [UsageEvent] = []
for dayOffset in 0..<7 {
let date = calendar.date(byAdding: .day, value: -dayOffset, to: now)!
let count = calendar.component(.weekday, from: date) == 4 ? 5 : 1
for _ in 0..<count {
events.append(UsageEvent(eventType: "action", timestamp: date))
}
}
let calculator = InsightCalculator()
let insights = calculator.weeklySummary(from: events, referenceDate: now)
let mostActive = insights.first { $0.title == "Most Active Day" }
#expect(mostActive?.value == "Wednesday")
}
@Test
func handlesEmptyEventList() async throws {
let calculator = InsightCalculator()
let insights = calculator.weeklySummary(from: [], referenceDate: Date())
#expect(!insights.isEmpty) // Should still return cards with zero values
let totalInsight = insights.first { $0.title == "Total Events" }
#expect(totalInsight?.value == "0")
}
@Test
func recorderBatchesWritesForPerformance() async throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: UsageEvent.self, configurations: config)
let context = ModelContext(container)
let recorder = UsageEventRecorder(modelContext: context)
// Record 100 events rapidly
for i in 0..<100 {
recorder.record(.custom("event_\(i)"))
}
// Flush pending writes
recorder.flush()
let descriptor = FetchDescriptor<UsageEvent>()
let count = try context.fetchCount(descriptor)
#expect(count == 100)
}Common Patterns
Record an Event
recorder?.record(.featureUsed, metadata: ["feature": "darkMode"])Calculate a Weekly Summary
let calculator = InsightCalculator()
let events = try modelContext.fetch(FetchDescriptor<UsageEvent>())
let insights = calculator.weeklySummary(from: events, referenceDate: Date())Render Insight Cards in a Grid
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
ForEach(insights) { insight in
InsightCardView(insight: insight)
}
}Generate and Share a Recap Image
let renderer = ImageRenderer(content: UsageRecapView(period: .week))
renderer.scale = 2.0
if let image = renderer.uiImage {
// Use with ShareLink or UIActivityViewController
}Gotchas
Privacy: All Data Stays On-Device
- Usage events are stored in SwiftData on the user's device only
- Never transmit usage data to a server (that's what
analytics-setupis for) - Clearly communicate to users that insights are computed locally
- Consider adding a "Delete My Data" option in settings
Performance with Large Datasets
- Fetch only the events needed for the current period (use
#Predicatewith date range) - For yearly summaries, pre-aggregate monthly totals instead of scanning all events
- Batch event recording to avoid excessive SwiftData writes (use
UsageEventRecorder) - Set a retention policy — auto-delete events older than 1-2 years
Calendar-Based Period Calculations
- Always use
Calendar.currentfor date math — never assume 7 days = 1 week - Respect the user's first-day-of-week setting (
calendar.firstWeekday) - Use
calendar.dateInterval(of: .weekOfYear, for: date)for accurate week boundaries - Handle timezone changes gracefully — store timestamps in UTC, display in local time
Swift Charts Considerations
- Swift Charts is iOS 16+ / macOS 13+, but works best with iOS 17+ for latest features
- Keep chart data points reasonable (7 for weekly, 30 for monthly, 12 for yearly)
- Use
.chartYScale(domain:)to prevent axis from starting at a misleading value - Provide VoiceOver descriptions with
.accessibilityLabelon chart marks
Trend Comparison Edge Cases
- First week of use has no "previous period" — show "New!" instead of a trend arrow
- Handle periods with zero events gracefully (don't divide by zero for averages)
- Percentage changes can be misleading for small numbers (1 -> 2 = +100%)
References
- templates.md — All production Swift templates
- Related:
generators/share-card— Render recap views as shareable images - Related:
generators/analytics-setup— Backend analytics (complements on-device insights) - Related:
generators/streak-tracker— Streak tracking pairs well with usage insights - Related:
generators/milestone-celebration— Celebrate milestones surfaced by insights
Usage Insights Code Templates
Production-ready Swift templates for user-facing usage statistics and insights. All code targets iOS 17+ / macOS 14+ and uses SwiftData, @Observable, Swift Charts, and modern Swift concurrency.
UsageEvent.swift
import Foundation
import SwiftData
/// A single user activity event recorded on-device.
///
/// Events are the raw data from which insights are computed.
/// All data stays on the user's device — never transmitted to a server.
///
/// Usage:
/// ```swift
/// let event = UsageEvent(
/// eventType: "taskCompleted",
/// metadata: ["category": "work"],
/// duration: 45.0
/// )
/// ```
@Model
final class UsageEvent {
/// The type of event (e.g., "taskCompleted", "sessionStarted", "featureUsed").
var eventType: String
/// When the event occurred.
var timestamp: Date
/// Optional key-value metadata for categorization and filtering.
/// Examples: ["category": "work"], ["screen": "editor"], ["feature": "darkMode"]
var metadata: [String: String]
/// Optional duration in seconds (for timed events like sessions).
var duration: TimeInterval?
init(
eventType: String,
timestamp: Date = .now,
metadata: [String: String] = [:],
duration: TimeInterval? = nil
) {
self.eventType = eventType
self.timestamp = timestamp
self.metadata = metadata
self.duration = duration
}
}
// MARK: - Well-Known Event Types
extension UsageEvent {
/// Predefined event type constants to avoid stringly-typed errors.
enum EventType {
static let sessionStarted = "sessionStarted"
static let sessionCompleted = "sessionCompleted"
static let taskCompleted = "taskCompleted"
static let featureUsed = "featureUsed"
static let screenViewed = "screenViewed"
static let itemCreated = "itemCreated"
static let itemDeleted = "itemDeleted"
static let searchPerformed = "searchPerformed"
static let shareAction = "shareAction"
static let settingsChanged = "settingsChanged"
/// Create a custom event type string.
static func custom(_ name: String) -> String { name }
}
}InsightResult.swift
import Foundation
/// A computed insight derived from usage events.
///
/// Represents a single stat card in the insights dashboard.
/// Each insight has a displayable value, an optional comparison
/// to the previous period, and a visualization hint.
struct InsightResult: Identifiable, Sendable {
let id = UUID()
/// Display title (e.g., "Total Tasks", "Most Active Day").
let title: String
/// The primary display value (e.g., "42", "Tuesday", "3h 20m").
let value: String
/// SF Symbol name for the card icon.
let iconName: String
/// Comparison to the previous period (e.g., +12%, -5%).
/// Nil if no previous period data is available.
let comparison: PeriodComparison?
/// The trend direction for visual indicators.
let trend: Trend
/// Hint for how this insight should be visualized.
let visualizationType: VisualizationType
/// Optional sparkline data points for mini chart on the card.
let sparklineData: [Double]?
}
// MARK: - Trend
extension InsightResult {
/// Trend direction compared to the previous period.
enum Trend: Sendable {
case up
case down
case stable
case newData // First period, no comparison available
/// SF Symbol name for the trend arrow.
var iconName: String {
switch self {
case .up: return "arrow.up.right"
case .down: return "arrow.down.right"
case .stable: return "arrow.right"
case .newData: return "sparkles"
}
}
/// Semantic color for the trend indicator.
/// Note: "up" is not always good (e.g., errors up = bad).
/// Use `isPositive` parameter to control color meaning.
var defaultIsPositive: Bool {
switch self {
case .up: return true
case .down: return false
case .stable, .newData: return true
}
}
}
}
// MARK: - Period Comparison
extension InsightResult {
/// Comparison data between current and previous periods.
struct PeriodComparison: Sendable {
/// Percentage change from previous period (e.g., 0.12 = +12%).
let percentageChange: Double
/// Absolute change (e.g., +5 events).
let absoluteChange: Int
/// Formatted display string (e.g., "+12%", "-5%", "Same").
var displayString: String {
if absoluteChange == 0 { return "Same" }
let sign = percentageChange > 0 ? "+" : ""
return "\(sign)\(Int(percentageChange * 100))%"
}
}
}
// MARK: - Visualization Type
extension InsightResult {
/// Hint for how the insight should be rendered.
enum VisualizationType: Sendable {
/// A large number or text value (e.g., "42 tasks").
case number
/// A bar or line chart.
case chart
/// A badge or achievement-style display.
case badge
/// A streak counter (e.g., "5-day streak").
case streak
}
}
// MARK: - Insight Period
/// The time period for which insights are calculated.
enum InsightPeriod: String, CaseIterable, Identifiable, Sendable {
case day = "Today"
case week = "This Week"
case month = "This Month"
case year = "This Year"
var id: String { rawValue }
/// Calendar component for date arithmetic.
var calendarComponent: Calendar.Component {
switch self {
case .day: return .day
case .week: return .weekOfYear
case .month: return .month
case .year: return .year
}
}
/// Number of data points for charts.
var chartDataPointCount: Int {
switch self {
case .day: return 24 // Hours
case .week: return 7 // Days
case .month: return 30 // Days
case .year: return 12 // Months
}
}
}InsightCalculator.swift
import Foundation
/// Pure computation engine that aggregates UsageEvents into InsightResults.
///
/// All methods are pure functions with no side effects — easy to test.
/// Uses Calendar for correct period grouping that respects the user's
/// locale, first-day-of-week, and timezone settings.
///
/// Usage:
/// ```swift
/// let calculator = InsightCalculator()
/// let insights = calculator.weeklySummary(from: events, referenceDate: .now)
/// ```
struct InsightCalculator: Sendable {
private let calendar: Calendar
init(calendar: Calendar = .current) {
self.calendar = calendar
}
// MARK: - Public API
/// Compute insights for the given period.
func summary(
from events: [UsageEvent],
period: InsightPeriod,
referenceDate: Date = .now
) -> [InsightResult] {
switch period {
case .day:
return dailySummary(from: events, referenceDate: referenceDate)
case .week:
return weeklySummary(from: events, referenceDate: referenceDate)
case .month:
return monthlySummary(from: events, referenceDate: referenceDate)
case .year:
return yearlySummary(from: events, referenceDate: referenceDate)
}
}
// MARK: - Weekly Summary
/// Compute weekly insights from events.
func weeklySummary(
from events: [UsageEvent],
referenceDate: Date = .now
) -> [InsightResult] {
let currentWeekEvents = eventsInPeriod(.weekOfYear, of: referenceDate, from: events)
let previousWeekEvents = eventsInPreviousPeriod(.weekOfYear, of: referenceDate, from: events)
var insights: [InsightResult] = []
// Total events
let totalCount = currentWeekEvents.count
let previousCount = previousWeekEvents.count
let comparison = makeComparison(current: totalCount, previous: previousCount)
insights.append(InsightResult(
title: "Total Events",
value: "\(totalCount)",
iconName: "chart.bar.fill",
comparison: comparison,
trend: trend(current: totalCount, previous: previousCount),
visualizationType: .number,
sparklineData: dailyCounts(for: currentWeekEvents, days: 7, referenceDate: referenceDate)
))
// Most active day
let mostActive = mostActiveDay(in: currentWeekEvents)
insights.append(InsightResult(
title: "Most Active Day",
value: mostActive ?? "—",
iconName: "calendar.badge.clock",
comparison: nil,
trend: mostActive != nil ? .stable : .newData,
visualizationType: .badge,
sparklineData: nil
))
// Daily average
let avgPerDay = totalCount > 0 ? Double(totalCount) / 7.0 : 0
let prevAvg = previousCount > 0 ? Double(previousCount) / 7.0 : 0
insights.append(InsightResult(
title: "Daily Average",
value: String(format: "%.1f", avgPerDay),
iconName: "divide.circle.fill",
comparison: makeComparison(current: Int(avgPerDay * 10), previous: Int(prevAvg * 10)),
trend: trend(current: Int(avgPerDay * 100), previous: Int(prevAvg * 100)),
visualizationType: .number,
sparklineData: nil
))
// Current streak
let streakDays = currentStreak(from: events, referenceDate: referenceDate)
insights.append(InsightResult(
title: "Current Streak",
value: "\(streakDays) day\(streakDays == 1 ? "" : "s")",
iconName: "flame.fill",
comparison: nil,
trend: streakDays > 0 ? .up : .stable,
visualizationType: .streak,
sparklineData: nil
))
// Top categories (from metadata)
let topCategory = topCategory(in: currentWeekEvents)
if let topCategory {
insights.append(InsightResult(
title: "Top Category",
value: topCategory,
iconName: "tag.fill",
comparison: nil,
trend: .stable,
visualizationType: .badge,
sparklineData: nil
))
}
// Total duration (if any events have duration)
let totalDuration = currentWeekEvents.compactMap(\.duration).reduce(0, +)
if totalDuration > 0 {
let prevDuration = previousWeekEvents.compactMap(\.duration).reduce(0, +)
insights.append(InsightResult(
title: "Total Time",
value: formattedDuration(totalDuration),
iconName: "clock.fill",
comparison: makeComparison(current: Int(totalDuration), previous: Int(prevDuration)),
trend: trend(current: Int(totalDuration), previous: Int(prevDuration)),
visualizationType: .number,
sparklineData: nil
))
}
return insights
}
// MARK: - Daily Summary
func dailySummary(
from events: [UsageEvent],
referenceDate: Date = .now
) -> [InsightResult] {
let todayEvents = eventsInPeriod(.day, of: referenceDate, from: events)
let yesterdayEvents = eventsInPreviousPeriod(.day, of: referenceDate, from: events)
let totalCount = todayEvents.count
let previousCount = yesterdayEvents.count
let comparison = makeComparison(current: totalCount, previous: previousCount)
var insights: [InsightResult] = []
insights.append(InsightResult(
title: "Total Events",
value: "\(totalCount)",
iconName: "chart.bar.fill",
comparison: comparison,
trend: trend(current: totalCount, previous: previousCount),
visualizationType: .number,
sparklineData: hourlyCounts(for: todayEvents, referenceDate: referenceDate)
))
// Most active hour
let peakHour = mostActiveHour(in: todayEvents)
if let peakHour {
insights.append(InsightResult(
title: "Peak Hour",
value: formattedHour(peakHour),
iconName: "clock.badge.fill",
comparison: nil,
trend: .stable,
visualizationType: .badge,
sparklineData: nil
))
}
// Total duration
let totalDuration = todayEvents.compactMap(\.duration).reduce(0, +)
if totalDuration > 0 {
let prevDuration = yesterdayEvents.compactMap(\.duration).reduce(0, +)
insights.append(InsightResult(
title: "Total Time",
value: formattedDuration(totalDuration),
iconName: "clock.fill",
comparison: makeComparison(current: Int(totalDuration), previous: Int(prevDuration)),
trend: trend(current: Int(totalDuration), previous: Int(prevDuration)),
visualizationType: .number,
sparklineData: nil
))
}
return insights
}
// MARK: - Monthly Summary
func monthlySummary(
from events: [UsageEvent],
referenceDate: Date = .now
) -> [InsightResult] {
let currentEvents = eventsInPeriod(.month, of: referenceDate, from: events)
let previousEvents = eventsInPreviousPeriod(.month, of: referenceDate, from: events)
var insights: [InsightResult] = []
let totalCount = currentEvents.count
let previousCount = previousEvents.count
insights.append(InsightResult(
title: "Total Events",
value: "\(totalCount)",
iconName: "chart.bar.fill",
comparison: makeComparison(current: totalCount, previous: previousCount),
trend: trend(current: totalCount, previous: previousCount),
visualizationType: .number,
sparklineData: dailyCounts(for: currentEvents, days: 30, referenceDate: referenceDate)
))
let mostActive = mostActiveDay(in: currentEvents)
insights.append(InsightResult(
title: "Most Active Day",
value: mostActive ?? "—",
iconName: "calendar.badge.clock",
comparison: nil,
trend: mostActive != nil ? .stable : .newData,
visualizationType: .badge,
sparklineData: nil
))
let avgPerDay = totalCount > 0 ? Double(totalCount) / 30.0 : 0
insights.append(InsightResult(
title: "Daily Average",
value: String(format: "%.1f", avgPerDay),
iconName: "divide.circle.fill",
comparison: nil,
trend: .stable,
visualizationType: .number,
sparklineData: nil
))
let streakDays = currentStreak(from: events, referenceDate: referenceDate)
insights.append(InsightResult(
title: "Current Streak",
value: "\(streakDays) day\(streakDays == 1 ? "" : "s")",
iconName: "flame.fill",
comparison: nil,
trend: streakDays > 0 ? .up : .stable,
visualizationType: .streak,
sparklineData: nil
))
return insights
}
// MARK: - Yearly Summary
func yearlySummary(
from events: [UsageEvent],
referenceDate: Date = .now
) -> [InsightResult] {
let currentEvents = eventsInPeriod(.year, of: referenceDate, from: events)
let previousEvents = eventsInPreviousPeriod(.year, of: referenceDate, from: events)
var insights: [InsightResult] = []
let totalCount = currentEvents.count
let previousCount = previousEvents.count
insights.append(InsightResult(
title: "Total Events",
value: "\(totalCount)",
iconName: "chart.bar.fill",
comparison: makeComparison(current: totalCount, previous: previousCount),
trend: trend(current: totalCount, previous: previousCount),
visualizationType: .number,
sparklineData: monthlyCounts(for: currentEvents, referenceDate: referenceDate)
))
// Best month
let bestMonth = mostActiveMonth(in: currentEvents)
if let bestMonth {
insights.append(InsightResult(
title: "Best Month",
value: bestMonth,
iconName: "trophy.fill",
comparison: nil,
trend: .stable,
visualizationType: .badge,
sparklineData: nil
))
}
// Longest streak in the year
let longestStreak = longestStreak(from: currentEvents)
insights.append(InsightResult(
title: "Longest Streak",
value: "\(longestStreak) day\(longestStreak == 1 ? "" : "s")",
iconName: "flame.fill",
comparison: nil,
trend: longestStreak > 0 ? .up : .stable,
visualizationType: .streak,
sparklineData: nil
))
// Unique active days
let activeDays = uniqueActiveDays(in: currentEvents)
insights.append(InsightResult(
title: "Active Days",
value: "\(activeDays)",
iconName: "calendar.badge.checkmark",
comparison: nil,
trend: .stable,
visualizationType: .number,
sparklineData: nil
))
// Top event type
let topType = topEventType(in: currentEvents)
if let topType {
insights.append(InsightResult(
title: "Top Activity",
value: topType,
iconName: "star.fill",
comparison: nil,
trend: .stable,
visualizationType: .badge,
sparklineData: nil
))
}
return insights
}
// MARK: - Period Filtering
private func eventsInPeriod(
_ component: Calendar.Component,
of date: Date,
from events: [UsageEvent]
) -> [UsageEvent] {
guard let interval = calendar.dateInterval(of: component, for: date) else { return [] }
return events.filter { interval.contains($0.timestamp) }
}
private func eventsInPreviousPeriod(
_ component: Calendar.Component,
of date: Date,
from events: [UsageEvent]
) -> [UsageEvent] {
guard let previousDate = calendar.date(byAdding: component, value: -1, to: date) else {
return []
}
return eventsInPeriod(component, of: previousDate, from: events)
}
// MARK: - Aggregation Helpers
private func mostActiveDay(in events: [UsageEvent]) -> String? {
guard !events.isEmpty else { return nil }
let grouped = Dictionary(grouping: events) { event in
calendar.component(.weekday, from: event.timestamp)
}
guard let (weekday, _) = grouped.max(by: { $0.value.count < $1.value.count }) else {
return nil
}
return calendar.weekdaySymbols[weekday - 1]
}
private func mostActiveHour(in events: [UsageEvent]) -> Int? {
guard !events.isEmpty else { return nil }
let grouped = Dictionary(grouping: events) { event in
calendar.component(.hour, from: event.timestamp)
}
return grouped.max(by: { $0.value.count < $1.value.count })?.key
}
private func mostActiveMonth(in events: [UsageEvent]) -> String? {
guard !events.isEmpty else { return nil }
let grouped = Dictionary(grouping: events) { event in
calendar.component(.month, from: event.timestamp)
}
guard let (month, _) = grouped.max(by: { $0.value.count < $1.value.count }) else {
return nil
}
return calendar.monthSymbols[month - 1]
}
private func topCategory(in events: [UsageEvent]) -> String? {
let categories = events.compactMap { $0.metadata["category"] }
guard !categories.isEmpty else { return nil }
let counts = Dictionary(grouping: categories) { $0 }
return counts.max(by: { $0.value.count < $1.value.count })?.key.capitalized
}
private func topEventType(in events: [UsageEvent]) -> String? {
guard !events.isEmpty else { return nil }
let counts = Dictionary(grouping: events) { $0.eventType }
return counts.max(by: { $0.value.count < $1.value.count })?.key
.replacingOccurrences(of: "([A-Z])", with: " $1", options: .regularExpression)
.capitalized
.trimmingCharacters(in: .whitespaces)
}
private func currentStreak(from events: [UsageEvent], referenceDate: Date) -> Int {
let startOfToday = calendar.startOfDay(for: referenceDate)
// Get unique active days sorted descending
let activeDays = Set(events.map { calendar.startOfDay(for: $0.timestamp) })
.sorted(by: >)
guard !activeDays.isEmpty else { return 0 }
// Check if today or yesterday is active (streak must be current)
let yesterday = calendar.date(byAdding: .day, value: -1, to: startOfToday)!
guard activeDays.first == startOfToday || activeDays.first == yesterday else { return 0 }
var streak = 0
var expectedDate = activeDays.first!
for day in activeDays {
if day == expectedDate {
streak += 1
expectedDate = calendar.date(byAdding: .day, value: -1, to: expectedDate)!
} else if day < expectedDate {
break
}
}
return streak
}
private func longestStreak(from events: [UsageEvent]) -> Int {
let activeDays = Set(events.map { calendar.startOfDay(for: $0.timestamp) })
.sorted()
guard !activeDays.isEmpty else { return 0 }
var longest = 1
var current = 1
for i in 1..<activeDays.count {
let expected = calendar.date(byAdding: .day, value: 1, to: activeDays[i - 1])!
if calendar.isDate(activeDays[i], inSameDayAs: expected) {
current += 1
longest = max(longest, current)
} else {
current = 1
}
}
return longest
}
private func uniqueActiveDays(in events: [UsageEvent]) -> Int {
Set(events.map { calendar.startOfDay(for: $0.timestamp) }).count
}
// MARK: - Sparkline Data
private func dailyCounts(
for events: [UsageEvent],
days: Int,
referenceDate: Date
) -> [Double] {
let startOfToday = calendar.startOfDay(for: referenceDate)
return (0..<days).reversed().map { dayOffset in
guard let day = calendar.date(byAdding: .day, value: -dayOffset, to: startOfToday) else {
return 0
}
let nextDay = calendar.date(byAdding: .day, value: 1, to: day)!
return Double(events.filter { $0.timestamp >= day && $0.timestamp < nextDay }.count)
}
}
private func hourlyCounts(for events: [UsageEvent], referenceDate: Date) -> [Double] {
let startOfDay = calendar.startOfDay(for: referenceDate)
return (0..<24).map { hour in
guard let hourStart = calendar.date(byAdding: .hour, value: hour, to: startOfDay),
let hourEnd = calendar.date(byAdding: .hour, value: 1, to: hourStart) else {
return 0
}
return Double(events.filter { $0.timestamp >= hourStart && $0.timestamp < hourEnd }.count)
}
}
private func monthlyCounts(for events: [UsageEvent], referenceDate: Date) -> [Double] {
guard let yearInterval = calendar.dateInterval(of: .year, for: referenceDate) else {
return []
}
return (0..<12).map { month in
guard let monthStart = calendar.date(byAdding: .month, value: month, to: yearInterval.start),
let monthInterval = calendar.dateInterval(of: .month, for: monthStart) else {
return 0
}
return Double(events.filter { monthInterval.contains($0.timestamp) }.count)
}
}
// MARK: - Formatting
private func makeComparison(current: Int, previous: Int) -> InsightResult.PeriodComparison? {
guard previous > 0 else {
if current > 0 {
return nil // No previous data, show as "New!"
}
return nil
}
let change = Double(current - previous) / Double(previous)
return InsightResult.PeriodComparison(
percentageChange: change,
absoluteChange: current - previous
)
}
private func trend(current: Int, previous: Int) -> InsightResult.Trend {
guard previous > 0 else { return .newData }
if current > previous { return .up }
if current < previous { return .down }
return .stable
}
private func formattedDuration(_ seconds: TimeInterval) -> String {
let hours = Int(seconds) / 3600
let minutes = (Int(seconds) % 3600) / 60
if hours > 0 {
return "\(hours)h \(minutes)m"
}
return "\(minutes)m"
}
private func formattedHour(_ hour: Int) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "ha"
let date = Calendar.current.date(from: DateComponents(hour: hour)) ?? Date()
return formatter.string(from: date).lowercased()
}
}InsightsDashboardView.swift
import SwiftUI
import SwiftData
import Charts
/// Main insights dashboard showing a grid of insight cards with a period picker.
///
/// Fetches usage events from SwiftData and computes insights on the fly.
/// Supports switching between daily, weekly, monthly, and yearly views.
///
/// Usage:
/// ```swift
/// NavigationLink("My Insights") {
/// InsightsDashboardView()
/// }
/// ```
struct InsightsDashboardView: View {
@Environment(\.modelContext) private var modelContext
@State private var selectedPeriod: InsightPeriod = .week
@State private var insights: [InsightResult] = []
@State private var chartData: [ChartDataPoint] = []
@State private var isLoading = true
private let calculator = InsightCalculator()
private let columns = [
GridItem(.flexible(), spacing: 16),
GridItem(.flexible(), spacing: 16)
]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
periodPicker
if isLoading {
loadingView
} else if insights.isEmpty {
emptyStateView
} else {
if !chartData.isEmpty {
activityChart
}
insightsGrid
}
}
.padding()
}
.navigationTitle("Insights")
.task(id: selectedPeriod) {
await loadInsights()
}
}
// MARK: - Period Picker
private var periodPicker: some View {
Picker("Period", selection: $selectedPeriod) {
ForEach(InsightPeriod.allCases) { period in
Text(period.rawValue).tag(period)
}
}
.pickerStyle(.segmented)
}
// MARK: - Activity Chart
private var activityChart: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Activity")
.font(.headline)
Chart(chartData) { point in
BarMark(
x: .value("Period", point.label),
y: .value("Count", point.value)
)
.foregroundStyle(.blue.gradient)
.cornerRadius(4)
}
.chartYScale(domain: 0...(chartData.map(\.value).max() ?? 1) * 1.1)
.frame(height: 200)
.accessibilityLabel("Activity chart showing \(selectedPeriod.rawValue.lowercased()) breakdown")
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.secondary.opacity(0.1))
)
}
// MARK: - Insights Grid
private var insightsGrid: some View {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(insights) { insight in
InsightCardView(insight: insight)
}
}
}
// MARK: - States
private var loadingView: some View {
VStack(spacing: 16) {
ProgressView()
Text("Calculating insights...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, minHeight: 200)
}
private var emptyStateView: some View {
ContentUnavailableView {
Label("No Activity Yet", systemImage: "chart.bar.doc.horizontal")
} description: {
Text("Start using the app and your insights will appear here.")
}
}
// MARK: - Data Loading
private func loadInsights() async {
isLoading = true
let referenceDate = Date.now
let fetchDescriptor = fetchDescriptor(for: selectedPeriod, referenceDate: referenceDate)
do {
let events = try modelContext.fetch(fetchDescriptor)
insights = calculator.summary(from: events, period: selectedPeriod, referenceDate: referenceDate)
chartData = buildChartData(from: events, period: selectedPeriod, referenceDate: referenceDate)
} catch {
insights = []
chartData = []
}
isLoading = false
}
/// Build a FetchDescriptor that only loads events relevant to the selected period.
/// This avoids loading the entire event history into memory.
private func fetchDescriptor(
for period: InsightPeriod,
referenceDate: Date
) -> FetchDescriptor<UsageEvent> {
let calendar = Calendar.current
// Fetch events for current period plus previous period (for comparison)
let periodsBack: Int
switch period {
case .day: periodsBack = 2
case .week: periodsBack = 2
case .month: periodsBack = 2
case .year: periodsBack = 2
}
let startDate = calendar.date(
byAdding: period.calendarComponent,
value: -periodsBack,
to: referenceDate
) ?? referenceDate
var descriptor = FetchDescriptor<UsageEvent>(
predicate: #Predicate<UsageEvent> { event in
event.timestamp >= startDate
},
sortBy: [SortDescriptor(\.timestamp, order: .reverse)]
)
descriptor.fetchLimit = 10_000 // Safety limit
return descriptor
}
/// Build chart data points from events for the selected period.
private func buildChartData(
from events: [UsageEvent],
period: InsightPeriod,
referenceDate: Date
) -> [ChartDataPoint] {
let calendar = Calendar.current
switch period {
case .day:
let startOfDay = calendar.startOfDay(for: referenceDate)
return (0..<24).map { hour in
let hourStart = calendar.date(byAdding: .hour, value: hour, to: startOfDay)!
let hourEnd = calendar.date(byAdding: .hour, value: 1, to: hourStart)!
let count = events.filter { $0.timestamp >= hourStart && $0.timestamp < hourEnd }.count
let formatter = DateFormatter()
formatter.dateFormat = "ha"
let label = formatter.string(from: hourStart).lowercased()
return ChartDataPoint(label: label, value: Double(count))
}
case .week:
let startOfToday = calendar.startOfDay(for: referenceDate)
return (0..<7).reversed().map { dayOffset in
let day = calendar.date(byAdding: .day, value: -dayOffset, to: startOfToday)!
let nextDay = calendar.date(byAdding: .day, value: 1, to: day)!
let count = events.filter { $0.timestamp >= day && $0.timestamp < nextDay }.count
let formatter = DateFormatter()
formatter.dateFormat = "EEE"
return ChartDataPoint(label: formatter.string(from: day), value: Double(count))
}
case .month:
let startOfToday = calendar.startOfDay(for: referenceDate)
return stride(from: 29, through: 0, by: -1).map { dayOffset in
let day = calendar.date(byAdding: .day, value: -dayOffset, to: startOfToday)!
let nextDay = calendar.date(byAdding: .day, value: 1, to: day)!
let count = events.filter { $0.timestamp >= day && $0.timestamp < nextDay }.count
let formatter = DateFormatter()
formatter.dateFormat = "d"
return ChartDataPoint(label: formatter.string(from: day), value: Double(count))
}
case .year:
guard let yearStart = calendar.dateInterval(of: .year, for: referenceDate)?.start else {
return []
}
return (0..<12).map { month in
let monthStart = calendar.date(byAdding: .month, value: month, to: yearStart)!
guard let monthInterval = calendar.dateInterval(of: .month, for: monthStart) else {
return ChartDataPoint(label: "", value: 0)
}
let count = events.filter { monthInterval.contains($0.timestamp) }.count
let formatter = DateFormatter()
formatter.dateFormat = "MMM"
return ChartDataPoint(label: formatter.string(from: monthStart), value: Double(count))
}
}
}
}
// MARK: - Chart Data Point
/// A single data point for the activity chart.
struct ChartDataPoint: Identifiable {
let id = UUID()
let label: String
let value: Double
}InsightCardView.swift
import SwiftUI
import Charts
/// Individual insight card displaying a single statistic.
///
/// Shows an icon, title, large value, trend indicator, and optional
/// mini sparkline chart. Matches system card styling with adaptive colors.
///
/// Usage:
/// ```swift
/// InsightCardView(insight: InsightResult(
/// title: "Total Tasks",
/// value: "42",
/// iconName: "checkmark.circle.fill",
/// comparison: nil,
/// trend: .up,
/// visualizationType: .number,
/// sparklineData: [3, 5, 2, 8, 6, 7, 4]
/// ))
/// ```
struct InsightCardView: View {
let insight: InsightResult
/// Controls whether "up" trend is shown as positive (green) or negative (red).
/// Default: true (up = good). Set to false for metrics where increase is bad (e.g., errors).
var upIsPositive: Bool = true
var body: some View {
VStack(alignment: .leading, spacing: 12) {
headerRow
valueSection
bottomSection
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.secondary.opacity(0.1))
)
.accessibilityElement(children: .combine)
.accessibilityLabel(accessibilityDescription)
}
// MARK: - Header
private var headerRow: some View {
HStack {
Image(systemName: insight.iconName)
.font(.title3)
.foregroundStyle(iconColor)
Spacer()
trendBadge
}
}
// MARK: - Value
private var valueSection: some View {
VStack(alignment: .leading, spacing: 4) {
Text(insight.value)
.font(.title2)
.fontWeight(.bold)
.lineLimit(1)
.minimumScaleFactor(0.7)
Text(insight.title)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
// MARK: - Bottom Section (Sparkline or Comparison)
@ViewBuilder
private var bottomSection: some View {
if let sparklineData = insight.sparklineData, !sparklineData.isEmpty {
sparklineChart(data: sparklineData)
} else if let comparison = insight.comparison {
comparisonLabel(comparison)
}
}
// MARK: - Trend Badge
@ViewBuilder
private var trendBadge: some View {
switch insight.trend {
case .newData:
Text("New!")
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(.blue)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
Capsule().fill(.blue.opacity(0.15))
)
case .up, .down, .stable:
Image(systemName: insight.trend.iconName)
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(trendColor)
}
}
// MARK: - Sparkline
private func sparklineChart(data: [Double]) -> some View {
Chart(Array(data.enumerated()), id: \.offset) { index, value in
LineMark(
x: .value("Index", index),
y: .value("Value", value)
)
.foregroundStyle(iconColor.gradient)
.interpolationMethod(.catmullRom)
AreaMark(
x: .value("Index", index),
y: .value("Value", value)
)
.foregroundStyle(iconColor.opacity(0.1).gradient)
.interpolationMethod(.catmullRom)
}
.chartXAxis(.hidden)
.chartYAxis(.hidden)
.frame(height: 40)
}
// MARK: - Comparison Label
private func comparisonLabel(_ comparison: InsightResult.PeriodComparison) -> some View {
Text("vs last period: \(comparison.displayString)")
.font(.caption2)
.foregroundStyle(.secondary)
}
// MARK: - Colors
private var iconColor: Color {
switch insight.visualizationType {
case .number: return .blue
case .chart: return .green
case .badge: return .orange
case .streak: return .red
}
}
private var trendColor: Color {
switch insight.trend {
case .up:
return upIsPositive ? .green : .red
case .down:
return upIsPositive ? .red : .green
case .stable:
return .secondary
case .newData:
return .blue
}
}
// MARK: - Accessibility
private var accessibilityDescription: String {
var description = "\(insight.title): \(insight.value)"
if let comparison = insight.comparison {
description += ", \(comparison.displayString) compared to last period"
}
switch insight.trend {
case .up: description += ", trending up"
case .down: description += ", trending down"
case .stable: description += ", stable"
case .newData: description += ", new data"
}
return description
}
}UsageRecapView.swift
import SwiftUI
import SwiftData
/// A "Year in Review" or "Weekly Recap" paged summary with shareable card generation.
///
/// Displays key insights in a paged, visually rich format inspired by
/// Spotify Wrapped and Apple's year-in-review screens.
///
/// Usage:
/// ```swift
/// .sheet(isPresented: $showRecap) {
/// UsageRecapView(period: .week)
/// }
/// ```
struct UsageRecapView: View {
let period: InsightPeriod
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State private var insights: [InsightResult] = []
@State private var currentPage = 0
@State private var isSharePresented = false
@State private var shareImage: Image?
private let calculator = InsightCalculator()
var body: some View {
NavigationStack {
ZStack {
backgroundGradient
if insights.isEmpty {
ContentUnavailableView {
Label("No Data", systemImage: "chart.bar.xaxis")
} description: {
Text("Not enough activity to generate a recap.")
}
} else {
pagedContent
}
}
.navigationTitle(period == .year ? "Year in Review" : "\(period.rawValue) Recap")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
if !insights.isEmpty {
shareButton
}
}
}
.task {
await loadInsights()
}
}
}
// MARK: - Background
private var backgroundGradient: some View {
LinearGradient(
colors: gradientColors,
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
}
private var gradientColors: [Color] {
switch period {
case .day: return [.blue.opacity(0.3), .cyan.opacity(0.2)]
case .week: return [.purple.opacity(0.3), .blue.opacity(0.2)]
case .month: return [.orange.opacity(0.3), .red.opacity(0.2)]
case .year: return [.indigo.opacity(0.4), .purple.opacity(0.3)]
}
}
// MARK: - Paged Content
private var pagedContent: some View {
TabView(selection: $currentPage) {
ForEach(Array(insights.enumerated()), id: \.element.id) { index, insight in
recapCard(insight: insight, index: index)
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .always))
}
private func recapCard(insight: InsightResult, index: Int) -> some View {
VStack(spacing: 32) {
Spacer()
Image(systemName: insight.iconName)
.font(.system(size: 60))
.foregroundStyle(.white.opacity(0.9))
VStack(spacing: 12) {
Text(insight.title)
.font(.title3)
.fontWeight(.medium)
.foregroundStyle(.white.opacity(0.8))
Text(insight.value)
.font(.system(size: 56, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.5)
if let comparison = insight.comparison {
Text(comparison.displayString + " vs last \(period.rawValue.lowercased().replacingOccurrences(of: "this ", with: ""))")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.7))
}
}
Spacer()
// Page indicator text
Text("\(index + 1) of \(insights.count)")
.font(.caption)
.foregroundStyle(.white.opacity(0.5))
.padding(.bottom, 32)
}
.padding(.horizontal, 32)
}
// MARK: - Share
@ViewBuilder
private var shareButton: some View {
if let shareImage {
ShareLink(
item: shareImage,
preview: SharePreview("My \(period.rawValue) Recap", image: shareImage)
) {
Label("Share", systemImage: "square.and.arrow.up")
}
} else {
Button {
Task { await generateShareImage() }
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
}
}
// MARK: - Data Loading
private func loadInsights() async {
let referenceDate = Date.now
let startDate = Calendar.current.date(
byAdding: period.calendarComponent,
value: -2,
to: referenceDate
) ?? referenceDate
var descriptor = FetchDescriptor<UsageEvent>(
predicate: #Predicate<UsageEvent> { event in
event.timestamp >= startDate
},
sortBy: [SortDescriptor(\.timestamp, order: .reverse)]
)
descriptor.fetchLimit = 10_000
do {
let events = try modelContext.fetch(descriptor)
insights = calculator.summary(from: events, period: period, referenceDate: referenceDate)
} catch {
insights = []
}
}
// MARK: - Share Image Generation
@MainActor
private func generateShareImage() async {
let shareView = RecapShareCard(insights: insights, period: period)
let renderer = ImageRenderer(content: shareView.frame(width: 390, height: 690))
renderer.scale = 2.0
#if canImport(UIKit)
if let uiImage = renderer.uiImage {
shareImage = Image(uiImage: uiImage)
}
#elseif canImport(AppKit)
if let nsImage = renderer.nsImage {
shareImage = Image(nsImage: nsImage)
}
#endif
}
}
// MARK: - Recap Share Card (Rendered to Image)
/// A self-contained view designed to be rendered to an image for sharing.
/// Fixed dimensions, no interactivity, optimized for image export.
private struct RecapShareCard: View {
let insights: [InsightResult]
let period: InsightPeriod
var body: some View {
VStack(spacing: 24) {
// Header
VStack(spacing: 8) {
Text(period == .year ? "Year in Review" : "\(period.rawValue) Recap")
.font(.title2)
.fontWeight(.bold)
Text(Date.now.formatted(.dateTime.month(.wide).year()))
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.top, 32)
// Top insights (max 4)
VStack(spacing: 16) {
ForEach(Array(insights.prefix(4))) { insight in
HStack {
Image(systemName: insight.iconName)
.frame(width: 32)
.foregroundStyle(.blue)
Text(insight.title)
.font(.subheadline)
Spacer()
Text(insight.value)
.font(.headline)
.fontWeight(.bold)
}
.padding(.horizontal)
if insight.id != insights.prefix(4).last?.id {
Divider()
.padding(.horizontal)
}
}
}
.padding(.vertical)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color.secondary.opacity(0.1))
)
.padding(.horizontal, 24)
Spacer()
// Branding
Text("Generated with MyApp")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.bottom, 24)
}
.background(Color(red: 0.98, green: 0.98, blue: 0.99))
}
}UsageEventRecorder.swift
import Foundation
import SwiftData
/// Convenience class for recording usage events from anywhere in the app.
///
/// Batches writes for performance — instead of saving after every event,
/// accumulates events and flushes periodically or when a threshold is reached.
///
/// Usage:
/// ```swift
/// struct ContentView: View {
/// @Environment(\.modelContext) private var modelContext
/// @State private var recorder: UsageEventRecorder?
///
/// var body: some View {
/// Button("Do Something") {
/// recorder?.record(.featureUsed, metadata: ["feature": "export"])
/// }
/// .onAppear {
/// recorder = UsageEventRecorder(modelContext: modelContext)
/// }
/// }
/// }
/// ```
@Observable
@MainActor
final class UsageEventRecorder {
private let modelContext: ModelContext
private var pendingEvents: [UsageEvent] = []
private let batchSize: Int
private var flushTask: Task<Void, Never>?
/// Create a recorder with the given model context.
/// - Parameters:
/// - modelContext: The SwiftData model context for persisting events.
/// - batchSize: Number of events to accumulate before auto-flushing. Default: 10.
init(modelContext: ModelContext, batchSize: Int = 10) {
self.modelContext = modelContext
self.batchSize = batchSize
}
// MARK: - Recording
/// Record a usage event with a predefined event type.
func record(
_ eventType: String,
metadata: [String: String] = [:],
duration: TimeInterval? = nil
) {
let event = UsageEvent(
eventType: eventType,
timestamp: .now,
metadata: metadata,
duration: duration
)
pendingEvents.append(event)
if pendingEvents.count >= batchSize {
flush()
} else {
scheduleDelayedFlush()
}
}
/// Record using the well-known event type constants.
func record(
_ eventType: UsageEvent.EventType.Type = UsageEvent.EventType.self,
type: String,
metadata: [String: String] = [:],
duration: TimeInterval? = nil
) {
record(type, metadata: metadata, duration: duration)
}
// MARK: - Convenience Methods
/// Record a screen view event.
func recordScreenView(_ screenName: String) {
record(UsageEvent.EventType.screenViewed, metadata: ["screen": screenName])
}
/// Record a feature usage event.
func recordFeatureUsed(_ featureName: String) {
record(UsageEvent.EventType.featureUsed, metadata: ["feature": featureName])
}
/// Record a task completion event.
func recordTaskCompleted(category: String? = nil, metadata: [String: String] = [:]) {
var allMetadata = metadata
if let category {
allMetadata["category"] = category
}
record(UsageEvent.EventType.taskCompleted, metadata: allMetadata)
}
/// Record a session with duration.
func recordSession(duration: TimeInterval, metadata: [String: String] = [:]) {
record(
UsageEvent.EventType.sessionCompleted,
metadata: metadata,
duration: duration
)
}
// MARK: - Flushing
/// Immediately persist all pending events to SwiftData.
func flush() {
flushTask?.cancel()
flushTask = nil
guard !pendingEvents.isEmpty else { return }
let eventsToSave = pendingEvents
pendingEvents.removeAll()
for event in eventsToSave {
modelContext.insert(event)
}
do {
try modelContext.save()
} catch {
// Re-add failed events to retry on next flush
pendingEvents.append(contentsOf: eventsToSave)
}
}
/// Schedule a delayed flush to catch any remaining events.
private func scheduleDelayedFlush() {
flushTask?.cancel()
flushTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(5))
guard !Task.isCancelled else { return }
self?.flush()
}
}
// MARK: - Cleanup
/// Delete all events older than the specified number of days.
/// Call periodically to prevent unbounded storage growth.
func pruneEvents(olderThanDays days: Int = 365) {
let cutoffDate = Calendar.current.date(byAdding: .day, value: -days, to: .now) ?? .now
do {
try modelContext.delete(
model: UsageEvent.self,
where: #Predicate<UsageEvent> { event in
event.timestamp < cutoffDate
}
)
try modelContext.save()
} catch {
// Log error but don't crash — pruning is best-effort
}
}
deinit {
flushTask?.cancel()
}
}
// MARK: - SwiftUI Environment Integration
private struct UsageEventRecorderKey: EnvironmentKey {
static let defaultValue: UsageEventRecorder? = nil
}
extension EnvironmentValues {
var usageRecorder: UsageEventRecorder? {
get { self[UsageEventRecorderKey.self] }
set { self[UsageEventRecorderKey.self] = newValue }
}
}