From fd8ca178a379da71f5bb19c461524888cad9447e Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 17 Jul 2026 11:18:25 -0500 Subject: v4.8.0: Add scheduled report generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- DomainDig/ScheduledReportsView.swift | 130 +++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 DomainDig/ScheduledReportsView.swift (limited to 'DomainDig/ScheduledReportsView.swift') 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) + } +} -- cgit v1.2.3