From a85324220eb80a5c38220d52cc33e553f9e9df76 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 00:12:46 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Cestino:=20purge=20delle=20attivit=C3=A0=20?= =?UTF-8?q?eliminate=20da=20oltre=2030=20giorni=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01AVhvvP7cDaXp6eV8BDfE5d --- CalenTask/App/AppShellView.swift | 1 + CalenTask/Services/TrashService.swift | 26 ++++++++++++++++++ CalenTaskTests/TrashServiceTests.swift | 38 ++++++++++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 CalenTask/Services/TrashService.swift create mode 100644 CalenTaskTests/TrashServiceTests.swift 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..95b0928 --- /dev/null +++ b/CalenTask/Services/TrashService.swift @@ -0,0 +1,26 @@ +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 && task.deletedAt! < cutoff + } + let expired = context.fetchOrLog(FetchDescriptor(predicate: predicate)) + let purgeable = expired.filter { $0.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"]) + } +} From b3eb258b0ee5cd99f5a3862d5e59db20d66404e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 00:16:35 +0000 Subject: [PATCH 2/2] Cestino: filtro sulla soglia in memoria, predicato solo su deletedAt (#22) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01AVhvvP7cDaXp6eV8BDfE5d --- CalenTask/Services/TrashService.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/CalenTask/Services/TrashService.swift b/CalenTask/Services/TrashService.swift index 95b0928..a6bdda1 100644 --- a/CalenTask/Services/TrashService.swift +++ b/CalenTask/Services/TrashService.swift @@ -14,10 +14,14 @@ enum TrashService { 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 && task.deletedAt! < cutoff + task.deletedAt != nil } + // Il cestino è piccolo: il filtro sulla soglia avviene in memoria. let expired = context.fetchOrLog(FetchDescriptor(predicate: predicate)) - let purgeable = expired.filter { $0.liveSubtasks.isEmpty } + 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()