summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-17 11:18:25 -0500
committerChristian Cleberg <[email protected]>2026-07-20 13:56:41 -0500
commitfd8ca178a379da71f5bb19c461524888cad9447e (patch)
treeadb481e4c7c22e41e919b97ccded0e506083f9b3
parent2b52ee5520cad3c47d30d513946645366f268762 (diff)
downloaddomain-dig-fd8ca178a379da71f5bb19c461524888cad9447e.tar.gz
domain-dig-fd8ca178a379da71f5bb19c461524888cad9447e.tar.bz2
domain-dig-fd8ca178a379da71f5bb19c461524888cad9447e.zip
v4.8.0: Add scheduled report generation
- ScheduledReportService (@MainActor, headless/storage-backed like DomainMonitoringService): builds the latest report for every tracked domain from persisted history, exports it via DomainReportExporter in the configured format (markdown/PDF/JSON), writes it to a local Documents subdirectory, logs the run, and fires a "Scheduled Report Ready" local notification. - ScheduledReportScheduler mirrors DomainMonitoringScheduler's BGTaskScheduler approach with its own task identifier (net.cleberg.DomainDig.report.schedule, added to Info.plist) and a daily/weekly cadence. - ScheduledReportsView (Settings → Scheduled Reports): enable toggle, cadence and format pickers, "Generate Now", and a log of past reports each shareable via the existing ExportPresenter share sheet. - Gated behind the existing .automatedMonitoring capability (Pro), consistent with monitoring being the other background-automation feature. - Settings/logs persist via UserDefaults (DomainExportFormat is now Codable), not DomainDataPortabilityService backup/restore — this is local automation configuration, not user-authored content, same reasoning as v4.7.0's watchlist saved views.
-rw-r--r--DomainDig/ContentView.swift4
-rw-r--r--DomainDig/DomainDigApp.swift3
-rw-r--r--DomainDig/Info.plist1
-rw-r--r--DomainDig/LocalNotificationService.swift9
-rw-r--r--DomainDig/ScheduledReportService.swift258
-rw-r--r--DomainDig/ScheduledReportsView.swift130
-rw-r--r--DomainReportExporter.swift2
7 files changed, 406 insertions, 1 deletions
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index d69cfa3..8f4eada 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -2716,6 +2716,10 @@ struct SettingsView: View {
NavigationLink("Monitoring") {
MonitoringSettingsView(viewModel: viewModel)
}
+
+ NavigationLink("Scheduled Reports") {
+ ScheduledReportsView()
+ }
}
Section("Data") {
diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift
index d34c51d..2f2070c 100644
--- a/DomainDig/DomainDigApp.swift
+++ b/DomainDig/DomainDigApp.swift
@@ -20,6 +20,7 @@ struct DomainDigApp: App {
init() {
LocalNotificationService.shared.configureForegroundPresentation()
DomainMonitoringScheduler.shared.registerBackgroundTask()
+ ScheduledReportScheduler.shared.registerBackgroundTask()
}
var body: some Scene {
@@ -38,6 +39,7 @@ struct DomainDigApp: App {
localAPIService.refresh()
cloudSyncService.scheduleSyncIfNeeded(trigger: .launch)
viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
+ ScheduledReportScheduler.shared.syncSchedule()
IntegrationService.shared.processQueueNow()
viewModel.refreshWidgetData()
consumeShareInbox()
@@ -58,6 +60,7 @@ struct DomainDigApp: App {
localAPIService.refresh()
cloudSyncService.scheduleSyncIfNeeded(trigger: .launch)
viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
+ ScheduledReportScheduler.shared.syncSchedule()
IntegrationService.shared.processQueueNow()
viewModel.refreshWidgetData()
consumeShareInbox()
diff --git a/DomainDig/Info.plist b/DomainDig/Info.plist
index 41b193b..2f05dea 100644
--- a/DomainDig/Info.plist
+++ b/DomainDig/Info.plist
@@ -9,6 +9,7 @@
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>net.cleberg.DomainDig.monitor.refresh</string>
+ <string>net.cleberg.DomainDig.report.schedule</string>
</array>
<key>CFBundleURLTypes</key>
<array>
diff --git a/DomainDig/LocalNotificationService.swift b/DomainDig/LocalNotificationService.swift
index f417619..d3474e3 100644
--- a/DomainDig/LocalNotificationService.swift
+++ b/DomainDig/LocalNotificationService.swift
@@ -140,6 +140,15 @@ final class LocalNotificationService {
)
}
+ func notifyScheduledReportReady(domainCount: Int) async {
+ await schedule(
+ identifier: "scheduled-report-\(UUID().uuidString)",
+ title: "Scheduled Report Ready",
+ body: "Report generated for \(domainCount) domain\(domainCount == 1 ? "" : "s").",
+ interruptionLevel: .active
+ )
+ }
+
func clearAllNotifications() async {
let center = UNUserNotificationCenter.current()
center.removeAllPendingNotificationRequests()
diff --git a/DomainDig/ScheduledReportService.swift b/DomainDig/ScheduledReportService.swift
new file mode 100644
index 0000000..c1db3de
--- /dev/null
+++ b/DomainDig/ScheduledReportService.swift
@@ -0,0 +1,258 @@
+import Foundation
+
+#if canImport(BackgroundTasks)
+import BackgroundTasks
+#endif
+
+#if canImport(UIKit)
+import UIKit
+#endif
+
+enum ScheduledReportCadence: String, Codable, CaseIterable, Identifiable {
+ case daily
+ case weekly
+
+ var id: String { rawValue }
+ var title: String { rawValue.capitalized }
+
+ var interval: TimeInterval {
+ switch self {
+ case .daily: return 86400
+ case .weekly: return 7 * 86400
+ }
+ }
+}
+
+struct ScheduledReportSettings: Codable, Equatable {
+ var isEnabled: Bool = false
+ var cadence: ScheduledReportCadence = .weekly
+ var format: DomainExportFormat = .markdown
+ var lastGeneratedAt: Date?
+}
+
+struct ScheduledReportLog: Codable, Identifiable, Equatable {
+ let id: UUID
+ let generatedAt: Date
+ let domainCount: Int
+ let format: DomainExportFormat
+ let fileName: String
+
+ init(
+ id: UUID = UUID(),
+ generatedAt: Date,
+ domainCount: Int,
+ format: DomainExportFormat,
+ fileName: String
+ ) {
+ self.id = id
+ self.generatedAt = generatedAt
+ self.domainCount = domainCount
+ self.format = format
+ self.fileName = fileName
+ }
+}
+
+/// Local, UserDefaults-backed persistence for scheduled-report settings and
+/// generation history. Deliberately not part of `DomainDataPortabilityService`
+/// backup/restore: this is local automation configuration, not user-authored
+/// content like tracked domains or audit sessions.
+enum ScheduledReportStorage {
+ private static let settingsKey = "scheduledReport.settings"
+ private static let logsKey = "scheduledReport.logs"
+ static let maxLogs = 20
+
+ static func loadSettings() -> ScheduledReportSettings {
+ guard let data = UserDefaults.standard.data(forKey: settingsKey),
+ let settings = try? JSONDecoder().decode(ScheduledReportSettings.self, from: data)
+ else { return ScheduledReportSettings() }
+ return settings
+ }
+
+ static func saveSettings(_ settings: ScheduledReportSettings) {
+ guard let data = try? JSONEncoder().encode(settings) else { return }
+ UserDefaults.standard.set(data, forKey: settingsKey)
+ }
+
+ static func loadLogs() -> [ScheduledReportLog] {
+ guard let data = UserDefaults.standard.data(forKey: logsKey),
+ let logs = try? JSONDecoder().decode([ScheduledReportLog].self, from: data)
+ else { return [] }
+ return logs
+ }
+
+ static func saveLogs(_ logs: [ScheduledReportLog]) {
+ guard let data = try? JSONEncoder().encode(Array(logs.prefix(maxLogs))) else { return }
+ UserDefaults.standard.set(data, forKey: logsKey)
+ }
+
+ static var reportsDirectory: URL {
+ let base = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
+ .appendingPathComponent("ScheduledReports", isDirectory: true)
+ try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
+ return base
+ }
+}
+
+struct ScheduledReportOutcome {
+ let success: Bool
+ let message: String
+ let log: ScheduledReportLog?
+}
+
+/// Generates a bundled report for all tracked domains and writes it to disk.
+/// Mirrors `DomainMonitoringService`'s headless, storage-backed design so it
+/// can run from a background task without a live view model.
+@MainActor
+final class ScheduledReportService {
+ static let shared = ScheduledReportService()
+
+ private init() {}
+
+ @discardableResult
+ func generateReport(trigger: MonitoringRunTrigger, requireEnabledSetting: Bool) async -> ScheduledReportOutcome {
+ var settings = ScheduledReportStorage.loadSettings()
+
+ guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
+ if settings.isEnabled {
+ settings.isEnabled = false
+ ScheduledReportStorage.saveSettings(settings)
+ ScheduledReportScheduler.shared.syncSchedule()
+ }
+ return ScheduledReportOutcome(success: false, message: "Scheduled reports require Pro.", log: nil)
+ }
+
+ if requireEnabledSetting, !settings.isEnabled {
+ return ScheduledReportOutcome(success: false, message: "Scheduled reports are disabled.", log: nil)
+ }
+
+ let trackedDomains = DomainDataPortabilityService.loadTrackedDomains()
+ guard !trackedDomains.isEmpty else {
+ return ScheduledReportOutcome(success: false, message: "No tracked domains to report on.", log: nil)
+ }
+
+ let history = DomainDataPortabilityService.loadHistoryEntries()
+ let builder = DomainReportBuilder()
+ let reports = trackedDomains.compactMap { trackedDomain -> DomainReport? in
+ let entry = history.first { entry in
+ if let trackedDomainID = entry.trackedDomainID {
+ return trackedDomainID == trackedDomain.id
+ }
+ return entry.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame
+ }
+ guard let entry else { return nil }
+ return builder.build(from: entry, historyEntries: history)
+ }
+
+ guard !reports.isEmpty else {
+ return ScheduledReportOutcome(success: false, message: "No history available for tracked domains yet.", log: nil)
+ }
+
+ guard let data = try? DomainReportExporter.data(for: reports, format: settings.format, title: "Scheduled Watchlist Report") else {
+ return ScheduledReportOutcome(success: false, message: "Report generation failed.", log: nil)
+ }
+
+ let generatedAt = Date()
+ let fileName = "\(Self.fileTimestampFormatter.string(from: generatedAt))_scheduled-report.\(settings.format.fileExtension)"
+ let fileURL = ScheduledReportStorage.reportsDirectory.appendingPathComponent(fileName)
+
+ do {
+ try data.write(to: fileURL, options: .atomic)
+ } catch {
+ return ScheduledReportOutcome(success: false, message: "Could not save report to disk.", log: nil)
+ }
+
+ let log = ScheduledReportLog(generatedAt: generatedAt, domainCount: reports.count, format: settings.format, fileName: fileName)
+ var logs = ScheduledReportStorage.loadLogs()
+ logs.insert(log, at: 0)
+ ScheduledReportStorage.saveLogs(logs)
+
+ settings.lastGeneratedAt = generatedAt
+ ScheduledReportStorage.saveSettings(settings)
+
+ await LocalNotificationService.shared.notifyScheduledReportReady(domainCount: reports.count)
+
+ return ScheduledReportOutcome(success: true, message: "Report generated for \(reports.count) domains.", log: log)
+ }
+
+ private static let fileTimestampFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyyMMdd_HHmmss"
+ return formatter
+ }()
+}
+
+/// Background scheduling for report generation, mirroring
+/// `DomainMonitoringScheduler`'s BGTaskScheduler-based approach with its own
+/// task identifier and cadence.
+@MainActor
+final class ScheduledReportScheduler {
+ static let shared = ScheduledReportScheduler()
+ static let taskIdentifier = "net.cleberg.DomainDig.report.schedule"
+
+ private var isRegistered = false
+
+ private init() {}
+
+ func registerBackgroundTask() {
+ #if canImport(BackgroundTasks)
+ guard !isRegistered else { return }
+ isRegistered = BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.taskIdentifier, using: nil) { task in
+ guard let refreshTask = task as? BGAppRefreshTask else {
+ task.setTaskCompleted(success: false)
+ return
+ }
+ self.handleAppRefresh(task: refreshTask)
+ }
+ #endif
+ }
+
+ @discardableResult
+ func syncSchedule() -> String? {
+ #if canImport(BackgroundTasks)
+ let settings = ScheduledReportStorage.loadSettings()
+ guard settings.isEnabled, FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
+ BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
+ return nil
+ }
+
+ #if canImport(UIKit)
+ guard UIApplication.shared.backgroundRefreshStatus == .available else {
+ BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
+ return "Background refresh is unavailable."
+ }
+ #endif
+
+ let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier)
+ let baseDate = settings.lastGeneratedAt ?? Date()
+ request.earliestBeginDate = max(
+ baseDate.addingTimeInterval(settings.cadence.interval),
+ Date(timeIntervalSinceNow: 15 * 60)
+ )
+
+ do {
+ BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
+ try BGTaskScheduler.shared.submit(request)
+ return nil
+ } catch {
+ return "Could not schedule reports."
+ }
+ #else
+ return "Scheduled reports are unavailable on this platform."
+ #endif
+ }
+
+ #if canImport(BackgroundTasks)
+ private func handleAppRefresh(task: BGAppRefreshTask) {
+ _ = syncSchedule()
+
+ let worker = Task {
+ let outcome = await ScheduledReportService.shared.generateReport(trigger: .background, requireEnabledSetting: true)
+ task.setTaskCompleted(success: outcome.success)
+ }
+
+ task.expirationHandler = {
+ worker.cancel()
+ }
+ }
+ #endif
+}
diff --git a/DomainDig/ScheduledReportsView.swift b/DomainDig/ScheduledReportsView.swift
new file mode 100644
index 0000000..f97ee77
--- /dev/null
+++ b/DomainDig/ScheduledReportsView.swift
@@ -0,0 +1,130 @@
+import SwiftUI
+
+struct ScheduledReportsView: View {
+ @Environment(\.appDensity) private var appDensity
+ @State private var settings = ScheduledReportStorage.loadSettings()
+ @State private var logs = ScheduledReportStorage.loadLogs()
+ @State private var isGenerating = false
+ @State private var statusMessage: String?
+
+ var body: some View {
+ List {
+ Section("Overview") {
+ VStack(alignment: .leading, spacing: 8) {
+ if !FeatureAccessService.hasAccess(to: .automatedMonitoring) {
+ Text("Scheduled reports require Pro.")
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.secondary)
+ }
+
+ Toggle("Scheduled Reports", isOn: Binding(
+ get: { settings.isEnabled },
+ set: { newValue in
+ settings.isEnabled = newValue
+ saveSettings()
+ }
+ ))
+ .disabled(!FeatureAccessService.hasAccess(to: .automatedMonitoring))
+
+ Picker("Cadence", selection: Binding(
+ get: { settings.cadence },
+ set: { newValue in
+ settings.cadence = newValue
+ saveSettings()
+ }
+ )) {
+ ForEach(ScheduledReportCadence.allCases) { cadence in
+ Text(cadence.title).tag(cadence)
+ }
+ }
+
+ Picker("Format", selection: Binding(
+ get: { settings.format },
+ set: { newValue in
+ settings.format = newValue
+ saveSettings()
+ }
+ )) {
+ ForEach([DomainExportFormat.markdown, .pdf, .json]) { format in
+ Text(format.title).tag(format)
+ }
+ }
+
+ LabeledContent(
+ "Last Generated",
+ value: settings.lastGeneratedAt?.formatted(date: .abbreviated, time: .shortened) ?? "Never"
+ )
+
+ if let statusMessage {
+ Text(statusMessage)
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.secondary)
+ }
+
+ Button(isGenerating ? "Generating…" : "Generate Now") {
+ Task { await generateNow() }
+ }
+ .disabled(isGenerating)
+ }
+ .padding(.vertical, 4)
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+ if logs.isEmpty {
+ Section {
+ EmptyStateCardView(
+ title: "No Reports Yet",
+ message: "Generated reports for your watchlist appear here after a manual or scheduled run.",
+ suggestion: "Tap Generate Now, or enable Scheduled Reports above.",
+ systemImage: "doc.text",
+ showsCardBackground: false
+ )
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+ } else {
+ Section("Recent Reports") {
+ ForEach(logs) { log in
+ Button {
+ share(log)
+ } label: {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("\(log.domainCount) domains • \(log.format.title)")
+ .foregroundStyle(.primary)
+ Text(log.generatedAt.formatted(date: .abbreviated, time: .shortened))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+ }
+ }
+ .scrollContentBackground(.hidden)
+ .background(Color.black)
+ .navigationTitle("Scheduled Reports")
+ }
+
+ private func saveSettings() {
+ ScheduledReportStorage.saveSettings(settings)
+ ScheduledReportScheduler.shared.syncSchedule()
+ }
+
+ private func generateNow() async {
+ isGenerating = true
+ let outcome = await ScheduledReportService.shared.generateReport(trigger: .manual, requireEnabledSetting: false)
+ statusMessage = outcome.message
+ settings = ScheduledReportStorage.loadSettings()
+ logs = ScheduledReportStorage.loadLogs()
+ isGenerating = false
+ }
+
+ private func share(_ log: ScheduledReportLog) {
+ let url = ScheduledReportStorage.reportsDirectory.appendingPathComponent(log.fileName)
+ guard let data = try? Data(contentsOf: url) else {
+ statusMessage = "That report is no longer available on disk."
+ return
+ }
+ ExportPresenter.share(filename: log.fileName, data: data)
+ }
+}
diff --git a/DomainReportExporter.swift b/DomainReportExporter.swift
index 1a04c70..7199d03 100644
--- a/DomainReportExporter.swift
+++ b/DomainReportExporter.swift
@@ -4,7 +4,7 @@ import Foundation
import UIKit
#endif
-enum DomainExportFormat: String, CaseIterable, Identifiable {
+enum DomainExportFormat: String, CaseIterable, Identifiable, Codable {
case text = "txt"
case csv = "csv"
case json = "json"