Skip to content

Commit 1934c26

Browse files
committed
Add predictive analytics and custom report services
1 parent bad9791 commit 1934c26

File tree

9 files changed

+262
-7
lines changed

9 files changed

+262
-7
lines changed

Modules/Analytics/Package.swift

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// swift-tools-version: 5.9
2+
//
3+
// Package.swift
4+
// Analytics Module
5+
//
6+
// Apple Configuration:
7+
// Bundle Identifier: com.homeinventory.app
8+
// Display Name: Home Inventory
9+
// Version: 1.0.5
10+
// Build: 5
11+
// Deployment Target: iOS 17.0
12+
// Supported Devices: iPhone & iPad
13+
// Team ID: 2VXBQV4XC9
14+
//
15+
// Makefile Configuration:
16+
// Default Simulator: iPhone 16 Pro Max (DD192264-DFAA-4582-B2FE-D6FC444C9DDF)
17+
// iPad Simulator: iPad Pro 13-inch (M4) (CE6D038C-840B-4BDB-AA63-D61FA0755C4A)
18+
// App Bundle ID: com.homeinventory.app
19+
// Build Path: build/Build/Products/Debug-iphonesimulator/
20+
//
21+
// Google Sign-In Configuration:
22+
// Client ID: 316432172622-6huvbn752v0ep68jkfdftrh8fgpesikg.apps.googleusercontent.com
23+
// URL Scheme: com.googleusercontent.apps.316432172622-6huvbn752v0ep68jkfdftrh8fgpesikg
24+
// OAuth Scope: https://www.googleapis.com/auth/gmail.readonly
25+
// Config Files: GoogleSignIn-Info.plist (project root), GoogleServices.plist (Gmail module)
26+
//
27+
// Key Commands:
28+
// Build and run: make build run
29+
// Fast build (skip module prebuild): make build-fast run
30+
// iPad build and run: make build-ipad run-ipad
31+
// Clean build: make clean build run
32+
// Run tests: make test
33+
//
34+
// Project Structure:
35+
// Main Target: HomeInventoryModular
36+
// Test Targets: HomeInventoryModularTests, HomeInventoryModularUITests
37+
// Swift Version: 5.9 (DO NOT upgrade to Swift 6)
38+
// Minimum iOS Version: 17.0
39+
//
40+
// Architecture: Modular SPM packages with local package dependencies
41+
// Repository: https://github.com/DrunkOnJava/ModularHomeInventory.git
42+
// Module: Analytics
43+
// Dependencies: Core
44+
// Testing: Modules/Analytics/Tests/AnalyticsTests.swift
45+
//
46+
// Description: Swift package manifest for advanced analytics features including
47+
// predictive analytics and custom report generation.
48+
//
49+
// Created by Griffin Long on June 25, 2025
50+
// Copyright © 2025 Home Inventory. All rights reserved.
51+
//
52+
// ⚠️ IMPORTANT: This project MUST use Swift 5.9 - DO NOT upgrade to Swift 6
53+
import PackageDescription
54+
55+
let package = Package(
56+
name: "Analytics",
57+
platforms: [.iOS(.v17)],
58+
products: [
59+
.library(
60+
name: "Analytics",
61+
targets: ["Analytics"]
62+
),
63+
],
64+
dependencies: [
65+
.package(path: "../Core")
66+
],
67+
targets: [
68+
.target(
69+
name: "Analytics",
70+
dependencies: ["Core"],
71+
path: "Sources"
72+
),
73+
.testTarget(
74+
name: "AnalyticsTests",
75+
dependencies: ["Analytics"],
76+
path: "Tests"
77+
),
78+
]
79+
)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import Foundation
2+
import Core
3+
4+
/// Service for generating custom analytics summaries
5+
/// Swift 5.9 - No Swift 6 features
6+
public final class CustomReportService {
7+
private let itemRepository: any ItemRepository
8+
9+
public init(itemRepository: any ItemRepository) {
10+
self.itemRepository = itemRepository
11+
}
12+
13+
/// Build a report grouped by category including total value
14+
public func generateCategoryReport() async throws -> [CategoryReport] {
15+
let items = try await itemRepository.fetchAll()
16+
let groups = Dictionary(grouping: items) { $0.category }
17+
return groups.map { category, items in
18+
let totalValue = items.reduce(Decimal.zero) { sum, item in
19+
sum + (item.value ?? item.purchasePrice ?? 0) * Decimal(item.quantity)
20+
}
21+
return CategoryReport(category: category, totalValue: totalValue, itemCount: items.count)
22+
}.sorted { $0.totalValue > $1.totalValue }
23+
}
24+
}
25+
26+
public struct CategoryReport: Identifiable {
27+
public let id = UUID()
28+
public let category: ItemCategory
29+
public let totalValue: Decimal
30+
public let itemCount: Int
31+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import Foundation
2+
import Core
3+
4+
/// Service for predicting future item values using a basic depreciation model
5+
/// Swift 5.9 - No Swift 6 features
6+
public final class FutureValuePredictionService {
7+
private let itemRepository: any ItemRepository
8+
private let calendar = Calendar.current
9+
10+
public init(itemRepository: any ItemRepository) {
11+
self.itemRepository = itemRepository
12+
}
13+
14+
/// Predict total inventory value on a future date
15+
public func predictInventoryValue(on date: Date) async throws -> Decimal {
16+
let items = try await itemRepository.fetchAll()
17+
return items.reduce(0) { sum, item in
18+
sum + (predictValue(for: item, on: date) ?? 0)
19+
}
20+
}
21+
22+
/// Predict the value of a single item on a future date
23+
public func predictValue(for item: Item, on date: Date) -> Decimal? {
24+
guard let purchasePrice = item.purchasePrice,
25+
let purchaseDate = item.purchaseDate else { return item.value }
26+
27+
let months = calendar.dateComponents([.month], from: purchaseDate, to: date).month ?? 0
28+
let monthlyRate: Decimal = 0.01 // 1% monthly depreciation
29+
let depreciation = purchasePrice * monthlyRate * Decimal(months)
30+
let predicted = max(purchasePrice - depreciation, purchasePrice * 0.2)
31+
return predicted * Decimal(item.quantity)
32+
}
33+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import XCTest
2+
@testable import Analytics
3+
4+
final class AnalyticsTests: XCTestCase {
5+
func testPlaceholder() {
6+
XCTAssertTrue(true)
7+
}
8+
}

Modules/Items/Package.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@ let package = Package(
1616
.package(path: "../SharedUI"),
1717
.package(path: "../BarcodeScanner"),
1818
.package(path: "../Receipts"),
19-
.package(path: "../Gmail")
19+
.package(path: "../Gmail"),
20+
.package(path: "../Analytics")
2021
],
2122
targets: [
2223
.target(
2324
name: "Items",
24-
dependencies: ["Core", "SharedUI", "BarcodeScanner", "Receipts", "Gmail"],
25+
dependencies: ["Core", "SharedUI", "BarcodeScanner", "Receipts", "Gmail", "Analytics"],
2526
path: "Sources"
2627
),
2728
.testTarget(

Modules/Items/Sources/Public/ItemsModule.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import SwiftUI
22
import Core
3+
import Analytics
34

45
/// Main implementation of the Items module
56
@MainActor
@@ -81,7 +82,9 @@ public final class ItemsModule: ItemsModuleAPI {
8182
itemRepository: dependencies.itemRepository,
8283
receiptRepository: dependencies.receiptRepository,
8384
budgetRepository: dependencies.budgetRepository,
84-
warrantyRepository: dependencies.warrantyRepository
85+
warrantyRepository: dependencies.warrantyRepository,
86+
predictionService: FutureValuePredictionService(itemRepository: dependencies.itemRepository),
87+
reportService: CustomReportService(itemRepository: dependencies.itemRepository)
8588
)
8689
return AnyView(
8790
SpendingDashboardView(viewModel: viewModel)

Modules/Items/Sources/ViewModels/SpendingDashboardViewModel.swift

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444

4545
import Foundation
4646
import Core
47+
import Analytics
4748
import Combine
4849

4950
/// View model for the spending dashboard
@@ -55,6 +56,8 @@ final class SpendingDashboardViewModel: ObservableObject {
5556
let receiptRepository: (any ReceiptRepository)?
5657
let budgetRepository: (any BudgetRepository)?
5758
let warrantyRepository: any WarrantyRepository
59+
let predictionService: FutureValuePredictionService
60+
let reportService: CustomReportService
5861

5962
// Published properties
6063
@Published var totalSpent: Decimal = 0
@@ -67,18 +70,30 @@ final class SpendingDashboardViewModel: ObservableObject {
6770
@Published var topRetailers: [RetailerSpendingData] = []
6871
@Published var isLoading = false
6972
@Published var spendingTrend: Double = 0
73+
@Published var predictedValueNextMonth: Decimal = 0
74+
@Published var predictedValueNextYear: Decimal = 0
75+
@Published var categoryReports: [CategoryReport] = []
7076

7177
let currency = "USD"
7278

7379
var hasEnoughDataForInsights: Bool {
7480
itemCount > 0 && (topCategories.count > 0 || topRetailers.count > 0)
7581
}
7682

77-
init(itemRepository: any ItemRepository, receiptRepository: (any ReceiptRepository)? = nil, budgetRepository: (any BudgetRepository)? = nil, warrantyRepository: any WarrantyRepository) {
83+
init(
84+
itemRepository: any ItemRepository,
85+
receiptRepository: (any ReceiptRepository)? = nil,
86+
budgetRepository: (any BudgetRepository)? = nil,
87+
warrantyRepository: any WarrantyRepository,
88+
predictionService: FutureValuePredictionService,
89+
reportService: CustomReportService
90+
) {
7891
self.itemRepository = itemRepository
7992
self.receiptRepository = receiptRepository
8093
self.budgetRepository = budgetRepository
8194
self.warrantyRepository = warrantyRepository
95+
self.predictionService = predictionService
96+
self.reportService = reportService
8297
}
8398

8499
func loadData(for timeRange: SpendingDashboardView.TimeRange) async {
@@ -105,10 +120,20 @@ final class SpendingDashboardViewModel: ObservableObject {
105120

106121
// Calculate top retailers (using brand as proxy for now)
107122
calculateTopRetailers(from: filteredItems)
108-
123+
109124
// Calculate spending trend if possible
110125
await calculateSpendingTrend(currentItems: filteredItems, timeRange: timeRange)
111-
126+
127+
// Predict future inventory values
128+
if let nextMonth = Calendar.current.date(byAdding: .month, value: 1, to: Date()),
129+
let nextYear = Calendar.current.date(byAdding: .year, value: 1, to: Date()) {
130+
predictedValueNextMonth = try await predictionService.predictInventoryValue(on: nextMonth)
131+
predictedValueNextYear = try await predictionService.predictInventoryValue(on: nextYear)
132+
}
133+
134+
// Generate category reports
135+
categoryReports = try await reportService.generateCategoryReport()
136+
112137
} catch {
113138
print("Error loading spending data: \(error)")
114139
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import SwiftUI
2+
import Analytics
3+
4+
/// Simple list to display custom category reports
5+
/// Swift 5.9 - No Swift 6 features
6+
struct CategoryReportListView: View {
7+
let reports: [CategoryReport]
8+
let currency: String
9+
10+
var body: some View {
11+
List(reports) { report in
12+
HStack {
13+
Text(report.category.displayName)
14+
Spacer()
15+
Text(report.totalValue.formatted(.currency(code: currency)))
16+
}
17+
.badge(report.itemCount)
18+
}
19+
.navigationTitle("Custom Report")
20+
}
21+
}

Modules/Items/Sources/Views/Analytics/SpendingDashboardView.swift

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ struct SpendingDashboardView: View {
6262
@State private var selectedTimeRange: TimeRange = .month
6363
@State private var showingCategoryDetail = false
6464
@State private var selectedCategory: ItemCategory?
65+
@State private var showingCustomReport = false
6566

6667
enum TimeRange: String, CaseIterable {
6768
case week = "Week"
@@ -124,6 +125,9 @@ struct SpendingDashboardView: View {
124125
)
125126
}
126127
}
128+
.sheet(isPresented: $showingCustomReport) {
129+
CategoryReportListView(reports: viewModel.categoryReports, currency: viewModel.currency)
130+
}
127131
}
128132

129133
// MARK: - Components
@@ -610,6 +614,28 @@ struct SpendingDashboardView: View {
610614
color: viewModel.spendingTrend > 0 ? AppColors.warning : AppColors.success
611615
)
612616
}
617+
618+
// Predicted value next month
619+
if viewModel.predictedValueNextMonth > 0 {
620+
QuickInsightCard(
621+
title: "Next Month",
622+
value: viewModel.predictedValueNextMonth.formatted(.currency(code: viewModel.currency)),
623+
subtitle: "Projected total value",
624+
icon: "calendar",
625+
color: AppColors.success
626+
)
627+
}
628+
629+
// Predicted value next year
630+
if viewModel.predictedValueNextYear > 0 {
631+
QuickInsightCard(
632+
title: "Next Year",
633+
value: viewModel.predictedValueNextYear.formatted(.currency(code: viewModel.currency)),
634+
subtitle: "Projected total value",
635+
icon: "calendar.badge.clock",
636+
color: AppColors.success
637+
)
638+
}
613639

614640
// Most frequent store
615641
if let topRetailer = viewModel.topRetailers.first {
@@ -647,9 +673,37 @@ struct SpendingDashboardView: View {
647673

648674
// Budget tracking link
649675
budgetTrackingLink
650-
676+
651677
// Warranty dashboard link
652678
warrantyDashboardLink
679+
680+
// Custom report link
681+
Button(action: { showingCustomReport = true }) {
682+
HStack(spacing: AppSpacing.md) {
683+
Image(systemName: "doc.badge.gearshape")
684+
.font(.system(size: 44))
685+
.foregroundStyle(AppColors.primary)
686+
687+
VStack(alignment: .leading, spacing: AppSpacing.xs) {
688+
Text("Custom Report")
689+
.textStyle(.headlineMedium)
690+
.foregroundStyle(AppColors.textPrimary)
691+
692+
Text("Generate category summary")
693+
.textStyle(.bodyMedium)
694+
.foregroundStyle(AppColors.textSecondary)
695+
}
696+
697+
Spacer()
698+
699+
Image(systemName: "chevron.right")
700+
.font(.system(size: 16))
701+
.foregroundStyle(AppColors.textTertiary)
702+
}
703+
.padding(AppSpacing.lg)
704+
.background(AppColors.surface)
705+
.cornerRadius(AppCornerRadius.large)
706+
}
653707
}
654708
}
655709
}

0 commit comments

Comments
 (0)