From 9b17d902f4205805e017e584dce7b7c846f9f0ce Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:05:29 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Errori=20non=20pi=C3=B9=20ingoiati=20su=20s?= =?UTF-8?q?alvataggi,=20letture=20e=20notifiche;=20conflitti=20filtrati=20?= =?UTF-8?q?nello=20store=20(#17,=20#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModelContext.saveOrLog / fetchOrLog: al posto di `try?` registrano l'errore nel log `store` e ritornano un valore neutro. - NotificationService: le richieste rifiutate dal sistema finiscono nel log (non quelle negate dall'utente); il riallineamento esce se la lettura fallisce. - Menu contestuale: Duplica e Salva come modello segnalano il fallimento. - ConflictService: assegnazioni e attività del giorno filtrate nello store invece di caricare tutte le attività. Test di regressione. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KJeAiFuQYJ2TxAu79ckWjT --- CalenTask/App/AppShellView.swift | 2 +- .../Components/TaskContextMenu.swift | 12 +++++- .../Features/Calendar/CalendarActions.swift | 4 +- .../Features/Capture/CaptureAssistView.swift | 4 +- .../Features/People/PersonDetailView.swift | 2 +- .../Production/ProjectStripboardView.swift | 2 +- .../Production/ShootDayEditorView.swift | 2 +- CalenTask/Models/TodoTask+Mutations.swift | 2 +- CalenTask/Services/AutomationEngine.swift | 4 +- CalenTask/Services/ConflictService.swift | 32 ++++++++-------- .../Services/ContactsImportService.swift | 2 +- CalenTask/Services/NotificationService.swift | 34 +++++++++++++---- CalenTask/Support/ModelContext+Logging.swift | 38 +++++++++++++++++++ CalenTaskTests/CalenTaskTests.swift | 34 +++++++++++++++++ 14 files changed, 138 insertions(+), 36 deletions(-) create mode 100644 CalenTask/Support/ModelContext+Logging.swift diff --git a/CalenTask/App/AppShellView.swift b/CalenTask/App/AppShellView.swift index 222a112..20e790f 100644 --- a/CalenTask/App/AppShellView.swift +++ b/CalenTask/App/AppShellView.swift @@ -221,7 +221,7 @@ struct AppShellView: View { // store; modelli e fasi si scartano in memoria come nel pannello Oggi. let dayEnd = calendar.date(byAdding: .day, value: 1, to: fireDay) ?? fireDay func fetch(_ predicate: Predicate) -> [TodoTask] { - ((try? modelContext.fetch(FetchDescriptor(predicate: predicate))) ?? []) + modelContext.fetchOrLog(FetchDescriptor(predicate: predicate)) .filter { !$0.isTemplate && $0.kind != .phase } } let starting = fetch(TodoTask.openStartingPredicate(from: fireDay, to: dayEnd, workspaceID: nil)) diff --git a/CalenTask/DesignSystem/Components/TaskContextMenu.swift b/CalenTask/DesignSystem/Components/TaskContextMenu.swift index 3491005..eac9097 100644 --- a/CalenTask/DesignSystem/Components/TaskContextMenu.swift +++ b/CalenTask/DesignSystem/Components/TaskContextMenu.swift @@ -216,12 +216,20 @@ private struct TaskMenuItems: View { Divider() Button { - try? TemplateService.duplicate(task, in: modelContext) + do { + try TemplateService.duplicate(task, in: modelContext) + } catch { + reportFailure("Duplicazione fallita: \(error)") + } } label: { Label("Duplica", systemImage: "plus.square.on.square") } Button { - try? TemplateService.makeTemplate(from: task, in: modelContext) + do { + try TemplateService.makeTemplate(from: task, in: modelContext) + } catch { + reportFailure("Salvataggio come modello fallito: \(error)") + } } label: { Label("Salva come modello", systemImage: "square.on.square.dashed") } diff --git a/CalenTask/Features/Calendar/CalendarActions.swift b/CalenTask/Features/Calendar/CalendarActions.swift index 928fdfe..7216f91 100644 --- a/CalenTask/Features/Calendar/CalendarActions.swift +++ b/CalenTask/Features/Calendar/CalendarActions.swift @@ -13,7 +13,7 @@ enum CalendarActions { static func reschedule(taskID idString: String, to day: Date, in context: ModelContext, calendar: Calendar) -> Bool { guard let id = UUID(uuidString: idString), - let task = try? context.fetch( + let task = context.fetchOrLog( FetchDescriptor(predicate: #Predicate { $0.id == id }) ).first, let anchor = task.startAt ?? task.dueAt @@ -52,7 +52,7 @@ enum CalendarActions { createdByID: me.id ) context.insert(task) - try? context.save() + context.saveOrLog() NotificationService.shared.sync(task: task) #if os(macOS) router.inspect(taskID: task.id) diff --git a/CalenTask/Features/Capture/CaptureAssistView.swift b/CalenTask/Features/Capture/CaptureAssistView.swift index 82f5a49..fa367cb 100644 --- a/CalenTask/Features/Capture/CaptureAssistView.swift +++ b/CalenTask/Features/Capture/CaptureAssistView.swift @@ -193,9 +193,9 @@ enum CaptureApplier { _ parsed: CaptureParser.Result, in context: ModelContext, workspaceID: UUID ) -> Project? { guard let query = parsed.projectQuery, !query.isEmpty else { return nil } - let projects = (try? context.fetch(FetchDescriptor( + let projects = context.fetchOrLog(FetchDescriptor( predicate: #Predicate { $0.deletedAt == nil } - ))) ?? [] + )) return projects.first { $0.name.localizedCaseInsensitiveContains(query) } } } diff --git a/CalenTask/Features/People/PersonDetailView.swift b/CalenTask/Features/People/PersonDetailView.swift index 1ed95d6..60ef675 100644 --- a/CalenTask/Features/People/PersonDetailView.swift +++ b/CalenTask/Features/People/PersonDetailView.swift @@ -34,7 +34,7 @@ struct PersonDetailView: View { let descriptor = FetchDescriptor(predicate: #Predicate { taskIDs.contains($0.id) && $0.deletedAt == nil }) - return ((try? modelContext.fetch(descriptor)) ?? []) + return modelContext.fetchOrLog(descriptor) .sorted { ($0.startAt ?? $0.dueAt ?? .distantPast) > ($1.startAt ?? $1.dueAt ?? .distantPast) } } diff --git a/CalenTask/Features/Production/ProjectStripboardView.swift b/CalenTask/Features/Production/ProjectStripboardView.swift index adde095..d4b0624 100644 --- a/CalenTask/Features/Production/ProjectStripboardView.swift +++ b/CalenTask/Features/Production/ProjectStripboardView.swift @@ -429,7 +429,7 @@ struct SceneEditorView: View { created.shootDayID = shootDayID modelContext.insert(created) } - try? modelContext.save() + modelContext.saveOrLog() dismiss() } } diff --git a/CalenTask/Features/Production/ShootDayEditorView.swift b/CalenTask/Features/Production/ShootDayEditorView.swift index fc33122..c42af3c 100644 --- a/CalenTask/Features/Production/ShootDayEditorView.swift +++ b/CalenTask/Features/Production/ShootDayEditorView.swift @@ -83,7 +83,7 @@ struct ShootDayEditorView: View { ToolbarItem(placement: .confirmationAction) { Button("Fine") { shootDay.touch() - try? modelContext.save() + modelContext.saveOrLog() dismiss() } } diff --git a/CalenTask/Models/TodoTask+Mutations.swift b/CalenTask/Models/TodoTask+Mutations.swift index daf4e76..4716dc9 100644 --- a/CalenTask/Models/TodoTask+Mutations.swift +++ b/CalenTask/Models/TodoTask+Mutations.swift @@ -289,7 +289,7 @@ extension TodoTask { $0.title == title && $0.deletedAt == nil && $0.statusRaw != doneRaw && $0.recurrenceFrequencyRaw == frequencyRaw && $0.id != ownID }) - let candidates = (try? context.fetch(descriptor)) ?? [] + let candidates = context.fetchOrLog(descriptor) let projectID = project?.id return candidates.contains { $0.dueAt == due && $0.startAt == start && $0.project?.id == projectID diff --git a/CalenTask/Services/AutomationEngine.swift b/CalenTask/Services/AutomationEngine.swift index 17f9e13..245d146 100644 --- a/CalenTask/Services/AutomationEngine.swift +++ b/CalenTask/Services/AutomationEngine.swift @@ -85,7 +85,7 @@ enum AutomationEngine { // 3. Notify a person (local notification on this device, v1). if let notifyID = rule.notifyUserID { - let person = try? context.fetch(FetchDescriptor( + let person = context.fetchOrLog(FetchDescriptor( predicate: #Predicate { $0.id == notifyID } )).first NotificationService.shared.postAutomationNotice( @@ -105,6 +105,6 @@ enum AutomationEngine { && $0.deletedAt == nil && $0.statusRaw != doneRaw }) let projectID = project?.id - return ((try? context.fetch(descriptor)) ?? []).contains { $0.project?.id == projectID } + return context.fetchOrLog(descriptor).contains { $0.project?.id == projectID } } } diff --git a/CalenTask/Services/ConflictService.swift b/CalenTask/Services/ConflictService.swift index 7c722c4..b6b9ff2 100644 --- a/CalenTask/Services/ConflictService.swift +++ b/CalenTask/Services/ConflictService.swift @@ -3,7 +3,7 @@ import SwiftData /// Conflitti di produzione (D55): la stessa persona o lo stesso attrezzo /// convocati in due posti nella stessa giornata — anche su progetti e spazi -/// diversi. Il controllo è in-memory: la scala è locale, la verità è chiara. +/// diversi. Assegnazioni e attività del giorno sono filtrate nello store. @MainActor enum ConflictService { /// Le ALTRE attività (giorni di ripresa o task) che convocano `contactID` @@ -12,20 +12,22 @@ enum ConflictService { contactID: UUID, day: Date, excludingTaskID taskID: UUID, in context: ModelContext ) -> [TodoTask] { - let assignments = (try? context.fetch(FetchDescriptor( + let assignments = context.fetchOrLog(FetchDescriptor( predicate: #Predicate { $0.deletedAt == nil && $0.contactID == contactID } - ))) ?? [] - let otherTaskIDs = Set(assignments.map(\.taskID)).subtracting([taskID]) + )) + let otherTaskIDs = Array(Set(assignments.map(\.taskID)).subtracting([taskID])) guard !otherTaskIDs.isEmpty else { return [] } + // #5 — solo quelle attività e solo in quel giorno, filtrate nello store. let calendar = Calendar.current - let tasks = (try? context.fetch(FetchDescriptor( - predicate: #Predicate { $0.deletedAt == nil } - ))) ?? [] - return tasks.filter { task in - otherTaskIDs.contains(task.id) - && task.startAt.map { calendar.isDate($0, inSameDayAs: day) } == true - } + let dayStart = calendar.startOfDay(for: day) + let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart + let past = Date.distantPast + let future = Date.distantFuture + return context.fetchOrLog(FetchDescriptor(predicate: #Predicate { task in + otherTaskIDs.contains(task.id) && task.deletedAt == nil + && (task.startAt ?? past) >= dayStart && (task.startAt ?? future) < dayEnd + })) } /// Tutti i contatti in conflitto per un giorno di ripresa. @@ -33,10 +35,10 @@ enum ConflictService { for shootDay: TodoTask, in context: ModelContext ) -> Set { guard let day = shootDay.startAt else { return [] } - let assignments = (try? context.fetch(FetchDescriptor( - predicate: #Predicate { $0.deletedAt == nil } - ))) ?? [] - let mine = assignments.filter { $0.taskID == shootDay.id } + let shootDayID = shootDay.id + let mine = context.fetchOrLog(FetchDescriptor( + predicate: #Predicate { $0.deletedAt == nil && $0.taskID == shootDayID } + )) var conflicted: Set = [] for assignment in mine where !conflicts( contactID: assignment.contactID, day: day, diff --git a/CalenTask/Services/ContactsImportService.swift b/CalenTask/Services/ContactsImportService.swift index 20e168f..29c1ebf 100644 --- a/CalenTask/Services/ContactsImportService.swift +++ b/CalenTask/Services/ContactsImportService.swift @@ -141,7 +141,7 @@ enum ContactsImportService { return false } update(contact, from: deviceContact) - try? context.save() + context.saveOrLog() return true } } diff --git a/CalenTask/Services/NotificationService.swift b/CalenTask/Services/NotificationService.swift index 622e00e..2094a49 100644 --- a/CalenTask/Services/NotificationService.swift +++ b/CalenTask/Services/NotificationService.swift @@ -44,9 +44,14 @@ final class NotificationService { /// (create su un altro dispositivo, dove `touch()` non è passato da qui) e /// i reinstalli. Tiene solo le più vicine, entro il limite di sistema. func resyncAll(in context: ModelContext) { - guard let tasks = try? context.fetch( - FetchDescriptor(predicate: TodoTask.openPredicate) - ) else { return } + // Se la lettura fallisce si esce: con `[]` si cancellerebbero tutti gli avvisi. + let tasks: [TodoTask] + do { + tasks = try context.fetch(FetchDescriptor(predicate: TodoTask.openPredicate)) + } catch { + Log.store.error("Riallineamento notifiche saltato: \(String(describing: error), privacy: .public)") + return + } let now = Date.now let planned = tasks .flatMap { Self.plannedRequests(for: NotificationPlan(task: $0), now: now) } @@ -64,7 +69,7 @@ final class NotificationService { await requestAuthorizationIfNeeded() for item in planned { guard generation == resyncGeneration else { return } - try? await center.add(item.request) + await schedule(item.request) } } } @@ -80,12 +85,27 @@ final class NotificationService { ) Task { await requestAuthorizationIfNeeded() - try? await center.add(request) + await schedule(request) } } // MARK: Internals + /// #17 — Programma la richiesta; un rifiuto del sistema finisce nel log + /// (prima `try?` lo ingoiava). Le notifiche negate dall'utente non sono + /// un errore: niente log, sarebbe una riga per ogni attività. + private func schedule(_ request: UNNotificationRequest) async { + do { + try await center.add(request) + } catch let error as UNError where error.code == .notificationsNotAllowed { + return + } catch { + Log.app.error( + "Notifica \(request.identifier, privacy: .public) non programmata: \(String(describing: error), privacy: .public)" + ) + } + } + private struct NotificationPlan { let taskID: UUID let title: String @@ -130,7 +150,7 @@ final class NotificationService { await requestAuthorizationIfNeeded() for item in planned { guard generations[plan.taskID] == generation else { return } - try? await center.add(item.request) + await schedule(item.request) } } @@ -274,7 +294,7 @@ final class NotificationService { ) Task { await requestAuthorizationIfNeeded() - try? await center.add(request) + await schedule(request) } } } diff --git a/CalenTask/Support/ModelContext+Logging.swift b/CalenTask/Support/ModelContext+Logging.swift new file mode 100644 index 0000000..99ffd42 --- /dev/null +++ b/CalenTask/Support/ModelContext+Logging.swift @@ -0,0 +1,38 @@ +import Foundation +import SwiftData + +/// #17 — Salvataggi e letture che non ingoiano l'errore. Con `try?` un +/// salvataggio o una fetch falliti sparivano senza traccia (l'azione "non +/// va" e basta); qui l'errore finisce nel log `store` (Console.app) e il +/// chiamante riceve un valore neutro, semplice come prima. +extension ModelContext { + /// Salva; se fallisce registra l'errore e ritorna `false`. + @discardableResult + nonisolated func saveOrLog(file: StaticString = #fileID, line: UInt = #line) -> Bool { + do { + try save() + return true + } catch { + let location = "\(file):\(line)" + Log.store.error( + "Salvataggio fallito: \(String(describing: error), privacy: .public) [\(location, privacy: .public)]" + ) + return false + } + } + + /// Esegue la fetch; se fallisce registra l'errore e ritorna `[]`. + nonisolated func fetchOrLog( + _ descriptor: FetchDescriptor, file: StaticString = #fileID, line: UInt = #line + ) -> [T] { + do { + return try fetch(descriptor) + } catch { + let location = "\(file):\(line)" + Log.store.error( + "Lettura di \(String(describing: T.self), privacy: .public) fallita: \(String(describing: error), privacy: .public) [\(location, privacy: .public)]" + ) + return [] + } + } +} diff --git a/CalenTaskTests/CalenTaskTests.swift b/CalenTaskTests/CalenTaskTests.swift index 5326056..11124a3 100644 --- a/CalenTaskTests/CalenTaskTests.swift +++ b/CalenTaskTests/CalenTaskTests.swift @@ -1251,6 +1251,40 @@ struct DomainModelTests { #expect(try titles(nil) == ["Recente", "Al limite", "Altro spazio"]) } + /// #5/#17 — conflitti di produzione filtrati nello store: solo le altre + /// convocazioni dello stesso contatto nello stesso giorno, vive. + @Test func conflictsFilterSameDayAssignmentsInStore() throws { + let container = try makeContainer() + let context = container.mainContext + let (workspace, me) = try SeedService.ensureSeed(in: context) + let calendar = Calendar.current + let day = calendar.date(from: DateComponents(year: 2026, month: 10, day: 7, hour: 9))! + func add(_ title: String, startAt: Date?) -> TodoTask { + let task = TodoTask(workspaceID: workspace.id, title: title, startAt: startAt, createdByID: me.id) + context.insert(task) + return task + } + let shootDay = add("Ripresa", startAt: day) + let sameDay = add("Stesso giorno", startAt: day.addingTimeInterval(6 * 3600)) + let nextDay = add("Giorno dopo", startAt: calendar.date(byAdding: .day, value: 1, to: day)) + let undated = add("Senza data", startAt: nil) + let deleted = add("Cancellata", startAt: day) + deleted.deletedAt = .now + let contactID = UUID() + let otherContactID = UUID() + for task in [shootDay, sameDay, nextDay, undated, deleted] { + context.insert(CrewAssignment(workspaceID: workspace.id, contactID: contactID, taskID: task.id)) + } + context.insert(CrewAssignment(workspaceID: workspace.id, contactID: otherContactID, taskID: shootDay.id)) + try context.save() + + let conflicts = ConflictService.conflicts( + contactID: contactID, day: day, excludingTaskID: shootDay.id, in: context + ) + #expect(conflicts.map(\.title) == ["Stesso giorno"]) + #expect(ConflictService.conflictedContactIDs(for: shootDay, in: context) == [contactID]) + } + @Test func freeSlotsSkipBusyIntervals() throws { let container = try makeContainer() let context = container.mainContext From f28f1b68d91313769d2699747302adcd48081c9a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:07:36 +0000 Subject: [PATCH 2/2] Import di os per Logger in ModelContext+Logging e NotificationService Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KJeAiFuQYJ2TxAu79ckWjT --- CalenTask/Services/NotificationService.swift | 1 + CalenTask/Support/ModelContext+Logging.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/CalenTask/Services/NotificationService.swift b/CalenTask/Services/NotificationService.swift index 2094a49..ff66dbf 100644 --- a/CalenTask/Services/NotificationService.swift +++ b/CalenTask/Services/NotificationService.swift @@ -1,4 +1,5 @@ import Foundation +import os import UserNotifications import Observation import SwiftData diff --git a/CalenTask/Support/ModelContext+Logging.swift b/CalenTask/Support/ModelContext+Logging.swift index 99ffd42..195c86a 100644 --- a/CalenTask/Support/ModelContext+Logging.swift +++ b/CalenTask/Support/ModelContext+Logging.swift @@ -1,4 +1,5 @@ import Foundation +import os import SwiftData /// #17 — Salvataggi e letture che non ingoiano l'errore. Con `try?` un