diff --git a/CalenTask/App/AppShellView.swift b/CalenTask/App/AppShellView.swift index 29ba310..16bfbbc 100644 --- a/CalenTask/App/AppShellView.swift +++ b/CalenTask/App/AppShellView.swift @@ -118,6 +118,7 @@ struct AppShellView: View { reportFailure("Seed failed: \(error)") } SmokeSeeder.seedIfRequested(in: modelContext) + TrashService.purgeExpired(in: modelContext) WidgetBridge.refresh(in: modelContext) WeatherService.shared.refreshIfNeeded() if !didShowWelcome { diff --git a/CalenTask/Services/TrashService.swift b/CalenTask/Services/TrashService.swift new file mode 100644 index 0000000..a6bdda1 --- /dev/null +++ b/CalenTask/Services/TrashService.swift @@ -0,0 +1,30 @@ +import Foundation +import SwiftData + +/// #22 — Cestino: elimina in modo definitivo le attività cancellate (soft +/// delete) da più di `retentionDays`, così DB e CloudKit non crescono per sempre. +enum TrashService { + static let retentionDays = 30 + + /// Hard delete delle task con `deletedAt` oltre la soglia. Salta quelle che + /// hanno ancora sotto-attività vive (non si orfanano figli). Ritorna quante + /// ne ha eliminate. + @MainActor + @discardableResult + static func purgeExpired(in context: ModelContext, now: Date = .now) -> Int { + guard let cutoff = Calendar.app.date(byAdding: .day, value: -retentionDays, to: now) else { return 0 } + let predicate = #Predicate { task in + task.deletedAt != nil + } + // Il cestino è piccolo: il filtro sulla soglia avviene in memoria. + let expired = context.fetchOrLog(FetchDescriptor(predicate: predicate)) + let purgeable = expired.filter { task in + guard let deletedAt = task.deletedAt else { return false } + return deletedAt < cutoff && task.liveSubtasks.isEmpty + } + guard !purgeable.isEmpty else { return 0 } + purgeable.forEach { context.delete($0) } + context.saveOrLog() + return purgeable.count + } +} diff --git a/CalenTaskTests/TrashServiceTests.swift b/CalenTaskTests/TrashServiceTests.swift new file mode 100644 index 0000000..3f3a536 --- /dev/null +++ b/CalenTaskTests/TrashServiceTests.swift @@ -0,0 +1,38 @@ +import Foundation +import SwiftData +import Testing +@testable import CalenTask + +/// #22 — `TrashService.purgeExpired`: elimina solo ciò che è nel cestino da oltre 30 giorni. +@MainActor +struct TrashServiceTests { + + private func makeContext() throws -> ModelContext { + let schema = Schema([ + Workspace.self, Membership.self, UserProfile.self, + Project.self, TodoTask.self, TaskDependency.self, Tag.self, + CustomFieldDefinition.self, CustomFieldValue.self, SavedView.self, + WorkflowStage.self, AutomationRule.self, Attachment.self, + Contact.self, CrewAssignment.self, ProductionScene.self, + ]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return ModelContext(try ModelContainer(for: schema, configurations: [config])) + } + + @Test func purgesOnlyExpiredDeletedTasks() throws { + let context = try makeContext() + let now = Date(timeIntervalSince1970: 10_000_000) + let author = UUID(), ws = UUID() + let old = TodoTask(workspaceID: ws, title: "Vecchia", createdByID: author) + old.deletedAt = now.addingTimeInterval(-31 * 86_400) + let recent = TodoTask(workspaceID: ws, title: "Recente", createdByID: author) + recent.deletedAt = now.addingTimeInterval(-5 * 86_400) + let live = TodoTask(workspaceID: ws, title: "Viva", createdByID: author) + [old, recent, live].forEach { context.insert($0) } + try context.save() + + #expect(TrashService.purgeExpired(in: context, now: now) == 1) + let titles = try context.fetch(FetchDescriptor()).map(\.title).sorted() + #expect(titles == ["Recente", "Viva"]) + } +}