From cbbf2864a2665fd3f24a90b1fff8c3bf2210ca4f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 00:02:26 +0000 Subject: [PATCH] Seed: creazioni senza seed completo, ripuntamento dei doppioni filtrato nello store (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SeedService.identity: spazio corrente e "me" salvati, ripiego su ensureSeed; le schermate di creazione non rifanno più dedupe, membership e save. - ensureSeed anche all'arrivo di dati iCloud (fusione dei doppioni). - repointWorkspace/repointUser: fetch con predicato invece di tutte le righe; ripuntati anche contatti, crew e scene (prima restavano nello spazio eliminato). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JW3843iLxaPe8vyErF6nNp --- CalenTask/App/AppShellView.swift | 7 ++ .../Features/Calendar/CalendarActions.swift | 2 +- .../Features/Calendar/CalendarScreen.swift | 4 +- .../Features/Capture/QuickCaptureView.swift | 2 +- .../Features/Capture/TaskComposerView.swift | 2 +- CalenTask/Features/Crew/CrewView.swift | 2 +- .../Features/Intake/IntakeFormView.swift | 2 +- .../Features/People/ContactsImportView.swift | 2 +- .../Features/Projects/NewProjectSheet.swift | 2 +- CalenTask/Features/Quick/QuickModeView.swift | 2 +- .../Features/SmartLists/SmartListView.swift | 2 +- CalenTask/Services/SeedService.swift | 100 ++++++++++++------ CalenTaskTests/CalenTaskTests.swift | 31 ++++++ 13 files changed, 118 insertions(+), 42 deletions(-) diff --git a/CalenTask/App/AppShellView.swift b/CalenTask/App/AppShellView.swift index 64ecf09..222a112 100644 --- a/CalenTask/App/AppShellView.swift +++ b/CalenTask/App/AppShellView.swift @@ -167,6 +167,13 @@ struct AppShellView: View { .publisher(for: .NSPersistentStoreRemoteChange) .debounce(for: .seconds(2), scheduler: RunLoop.main) ) { _ in + // #12 — le creazioni non rifanno più il seed: i doppioni "Personale" + // e "me" portati da iCloud si fondono qui, all'arrivo. + do { + try SeedService.ensureSeed(in: modelContext) + } catch { + reportFailure("Seed after remote change failed: \(error)") + } // #14 — il widget si aggiorna anche con l'app in background. WidgetBridge.refresh(in: modelContext) guard scenePhase == .active else { return } diff --git a/CalenTask/Features/Calendar/CalendarActions.swift b/CalenTask/Features/Calendar/CalendarActions.swift index fb8ae91..928fdfe 100644 --- a/CalenTask/Features/Calendar/CalendarActions.swift +++ b/CalenTask/Features/Calendar/CalendarActions.swift @@ -41,7 +41,7 @@ enum CalendarActions { /// apre l'editor (i dettagli si mettono lì). static func createTask(start: Date, end: Date, in context: ModelContext, router: AppRouter) { do { - let (workspace, me) = try SeedService.ensureSeed(in: context) + let (workspace, me) = try SeedService.identity(in: context) let target = WorkspaceScope.creationTarget(in: context, fallback: workspace) let task = TodoTask( workspaceID: target.id, diff --git a/CalenTask/Features/Calendar/CalendarScreen.swift b/CalenTask/Features/Calendar/CalendarScreen.swift index a3e99a0..98e4469 100644 --- a/CalenTask/Features/Calendar/CalendarScreen.swift +++ b/CalenTask/Features/Calendar/CalendarScreen.swift @@ -1373,7 +1373,7 @@ struct CalendarScreen: View { private func createBlock(at slot: Date) { do { - let (workspace, me) = try SeedService.ensureSeed(in: modelContext) + let (workspace, me) = try SeedService.identity(in: modelContext) let target = WorkspaceScope.creationTarget(in: modelContext, fallback: workspace) let event = TodoTask( workspaceID: target.id, @@ -1442,7 +1442,7 @@ struct CalendarScreen: View { isSyncing = true defer { isSyncing = false } do { - let (workspace, me) = try SeedService.ensureSeed(in: modelContext) + let (workspace, me) = try SeedService.identity(in: modelContext) await calendarSync.syncNow( in: modelContext, workspaceID: workspace.id, createdBy: me.id ) diff --git a/CalenTask/Features/Capture/QuickCaptureView.swift b/CalenTask/Features/Capture/QuickCaptureView.swift index f523004..fc41e41 100644 --- a/CalenTask/Features/Capture/QuickCaptureView.swift +++ b/CalenTask/Features/Capture/QuickCaptureView.swift @@ -125,7 +125,7 @@ struct QuickCaptureView: View { let text = trimmedTitle guard !text.isEmpty else { return } do { - let (workspace, me) = try SeedService.ensureSeed(in: modelContext) + let (workspace, me) = try SeedService.identity(in: modelContext) let target = WorkspaceScope.creationTarget(raw: scopeRaw, workspaces: workspaces) ?? workspace let task = TodoTask( workspaceID: target.id, diff --git a/CalenTask/Features/Capture/TaskComposerView.swift b/CalenTask/Features/Capture/TaskComposerView.swift index 3ab272f..7af7038 100644 --- a/CalenTask/Features/Capture/TaskComposerView.swift +++ b/CalenTask/Features/Capture/TaskComposerView.swift @@ -292,7 +292,7 @@ struct TaskComposerView: View { private func save() { guard !trimmedTitle.isEmpty else { return } do { - let (workspace, me) = try SeedService.ensureSeed(in: modelContext) + let (workspace, me) = try SeedService.identity(in: modelContext) let target = WorkspaceScope.creationTarget(raw: scopeRaw, workspaces: workspaces) ?? workspace let task = TodoTask( workspaceID: selectedProject?.workspaceID ?? target.id, diff --git a/CalenTask/Features/Crew/CrewView.swift b/CalenTask/Features/Crew/CrewView.swift index d2602e7..223f961 100644 --- a/CalenTask/Features/Crew/CrewView.swift +++ b/CalenTask/Features/Crew/CrewView.swift @@ -207,7 +207,7 @@ struct ContactEditorView: View { contact.notes = notes contact.updatedAt = .now } else { - let (workspace, _) = try SeedService.ensureSeed(in: modelContext) + let (workspace, _) = try SeedService.identity(in: modelContext) let scopeRaw = UserDefaults.standard.string(forKey: WorkspaceScope.storageKey) ?? "all" let workspaceID = UUID(uuidString: scopeRaw) ?? workspace.id let created = Contact( diff --git a/CalenTask/Features/Intake/IntakeFormView.swift b/CalenTask/Features/Intake/IntakeFormView.swift index fefd75f..2518701 100644 --- a/CalenTask/Features/Intake/IntakeFormView.swift +++ b/CalenTask/Features/Intake/IntakeFormView.swift @@ -79,7 +79,7 @@ struct IntakeFormView: View { private func submit() { do { - let (workspace, me) = try SeedService.ensureSeed(in: modelContext) + let (workspace, me) = try SeedService.identity(in: modelContext) // Le richieste vivono nello spazio società (mai in Personale): lo // spazio attivo se è uno spazio condiviso (A2 — è il caso comune, // l'Intake si apre da lì), altrimenti il primo spazio condiviso. diff --git a/CalenTask/Features/People/ContactsImportView.swift b/CalenTask/Features/People/ContactsImportView.swift index 4c50b35..3313a25 100644 --- a/CalenTask/Features/People/ContactsImportView.swift +++ b/CalenTask/Features/People/ContactsImportView.swift @@ -138,7 +138,7 @@ struct ContactsImportView: View { private func importSelected() { let picks = deviceContacts.filter { selection.contains($0.id) } do { - let (workspace, _) = try SeedService.ensureSeed(in: modelContext) + let (workspace, _) = try SeedService.identity(in: modelContext) let workspaceID = UUID(uuidString: scopeRaw) ?? workspace.id try ContactsImportService.importContacts( picks, workspaceID: workspaceID, into: modelContext diff --git a/CalenTask/Features/Projects/NewProjectSheet.swift b/CalenTask/Features/Projects/NewProjectSheet.swift index 4ddb7b4..038be7c 100644 --- a/CalenTask/Features/Projects/NewProjectSheet.swift +++ b/CalenTask/Features/Projects/NewProjectSheet.swift @@ -97,7 +97,7 @@ struct NewProjectSheet: View { private func create() { do { - let (workspace, me) = try SeedService.ensureSeed(in: modelContext) + let (workspace, me) = try SeedService.identity(in: modelContext) let target = WorkspaceScope.creationTarget(raw: scopeRaw, workspaces: workspaces) ?? workspace try TemplateService.apply( selectedTemplate, diff --git a/CalenTask/Features/Quick/QuickModeView.swift b/CalenTask/Features/Quick/QuickModeView.swift index c71fafc..9753e47 100644 --- a/CalenTask/Features/Quick/QuickModeView.swift +++ b/CalenTask/Features/Quick/QuickModeView.swift @@ -493,7 +493,7 @@ private struct QuickModeContent: View { let text = newTitle.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } do { - let (_, me) = try SeedService.ensureSeed(in: modelContext) + let (_, me) = try SeedService.identity(in: modelContext) guard let workspace = captureWorkspace else { return } let task = TodoTask( workspaceID: workspace.id, diff --git a/CalenTask/Features/SmartLists/SmartListView.swift b/CalenTask/Features/SmartLists/SmartListView.swift index 61a632e..023c3c9 100644 --- a/CalenTask/Features/SmartLists/SmartListView.swift +++ b/CalenTask/Features/SmartLists/SmartListView.swift @@ -245,7 +245,7 @@ struct SmartListEditorView: View { savedView.filters = filters savedView.updatedAt = .now } else { - let (workspace, _) = try SeedService.ensureSeed(in: modelContext) + let (workspace, _) = try SeedService.identity(in: modelContext) let scopeRaw = UserDefaults.standard.string(forKey: WorkspaceScope.storageKey) ?? "all" let workspaceID = UUID(uuidString: scopeRaw) ?? workspace.id let created = SavedView( diff --git a/CalenTask/Services/SeedService.swift b/CalenTask/Services/SeedService.swift index 340ccb0..8e7e4f0 100644 --- a/CalenTask/Services/SeedService.swift +++ b/CalenTask/Services/SeedService.swift @@ -44,6 +44,18 @@ enum SeedService { return (current, me) } + /// #12 — per le creazioni: spazio corrente e "me" già salvati, senza + /// rifare a ogni nuova attività dedupe, membership e `save` del seed + /// completo (quello gira all'avvio e quando arrivano dati da iCloud). + /// Ripiega su `ensureSeed` se uno dei due manca (primo avvio, eliminato). + static func identity(in context: ModelContext) throws -> (workspace: Workspace, me: UserProfile) { + if let workspace = try currentWorkspace(in: context), + let me = try savedUser(in: context) { + return (workspace, me) + } + return try ensureSeed(in: context) + } + /// Crea uno spazio di lavoro (la società, un team, un cliente) con la /// membership owner dell'utente corrente. Ritorna lo spazio creato. @discardableResult @@ -70,6 +82,17 @@ enum SeedService { return try context.fetch(descriptor).first } + private static func savedUser(in context: ModelContext) throws -> UserProfile? { + guard + let raw = UserDefaults.standard.string(forKey: userDefaultsKey), + let id = UUID(uuidString: raw) + else { return nil } + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id && $0.deletedAt == nil } + ) + return try context.fetch(descriptor).first + } + static func select(workspace: Workspace) { UserDefaults.standard.set(workspace.id.uuidString, forKey: workspaceDefaultsKey) } @@ -163,31 +186,41 @@ enum SeedService { } } + // #12 — il ripuntamento legge dallo store solo i record del doppione, non + // tutte le attività (e tutti gli altri record) dell'archivio. private static func repointWorkspace( from old: UUID, to new: UUID, in context: ModelContext ) throws { - for task in try context.fetch(FetchDescriptor()) - where task.workspaceID == old { task.workspaceID = new } - for project in try context.fetch(FetchDescriptor()) - where project.workspaceID == old { project.workspaceID = new } - for tag in try context.fetch(FetchDescriptor()) - where tag.workspaceID == old { tag.workspaceID = new } - for stage in try context.fetch(FetchDescriptor()) - where stage.workspaceID == old { stage.workspaceID = new } - for rule in try context.fetch(FetchDescriptor()) - where rule.workspaceID == old { rule.workspaceID = new } - for field in try context.fetch(FetchDescriptor()) - where field.workspaceID == old { field.workspaceID = new } - for value in try context.fetch(FetchDescriptor()) - where value.workspaceID == old { value.workspaceID = new } - for view in try context.fetch(FetchDescriptor()) - where view.workspaceID == old { view.workspaceID = new } - for attachment in try context.fetch(FetchDescriptor()) - where attachment.workspaceID == old { attachment.workspaceID = new } - for dependency in try context.fetch(FetchDescriptor()) - where dependency.workspaceID == old { dependency.workspaceID = new } - for membership in try context.fetch(FetchDescriptor()) - where membership.workspaceID == old { membership.deletedAt = .now } + for task in try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.workspaceID == old })) { task.workspaceID = new } + for project in try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.workspaceID == old })) { project.workspaceID = new } + for tag in try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.workspaceID == old })) { tag.workspaceID = new } + for stage in try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.workspaceID == old })) { stage.workspaceID = new } + for rule in try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.workspaceID == old })) { rule.workspaceID = new } + for field in try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.workspaceID == old })) { field.workspaceID = new } + for value in try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.workspaceID == old })) { value.workspaceID = new } + for view in try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.workspaceID == old })) { view.workspaceID = new } + for attachment in try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.workspaceID == old })) { attachment.workspaceID = new } + for dependency in try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.workspaceID == old })) { dependency.workspaceID = new } + // Contatti, crew e scene stavano fuori dall'elenco: restavano in uno + // spazio eliminato, invisibili. + for contact in try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.workspaceID == old })) { contact.workspaceID = new } + for crew in try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.workspaceID == old })) { crew.workspaceID = new } + for scene in try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.workspaceID == old })) { scene.workspaceID = new } + for membership in try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.workspaceID == old })) { membership.deletedAt = .now } // Lo scope/preferenze puntavano al doppione? Riallinea. if UserDefaults.standard.string(forKey: workspaceDefaultsKey) == old.uuidString { UserDefaults.standard.set(new.uuidString, forKey: workspaceDefaultsKey) @@ -197,20 +230,25 @@ enum SeedService { private static func repointUser( from old: UUID, to new: UUID, in context: ModelContext ) throws { - for task in try context.fetch(FetchDescriptor()) { - if task.createdByID == old { task.createdByID = new } - if task.assigneeID == old { task.assigneeID = new } - if task.delegatedByID == old { task.delegatedByID = new } - } - for project in try context.fetch(FetchDescriptor()) - where project.createdByID == old { project.createdByID = new } + // Predicati piccoli, uno per campo: una disgiunzione su tre campi, due + // opzionali, supera il limite del type-checker. + let optionalOld: UUID? = old + for task in try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.createdByID == old })) { task.createdByID = new } + for task in try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.assigneeID == optionalOld })) { task.assigneeID = new } + for task in try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.delegatedByID == optionalOld })) { task.delegatedByID = new } + for project in try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.createdByID == old })) { project.createdByID = new } + // Le regole sono poche: filtrarle in memoria va bene. for rule in try context.fetch(FetchDescriptor()) { if rule.createTaskAssigneeID == old { rule.createTaskAssigneeID = new } if rule.assignToID == old { rule.assignToID = new } if rule.notifyUserID == old { rule.notifyUserID = new } } - for membership in try context.fetch(FetchDescriptor()) - where membership.userID == old { membership.deletedAt = .now } + for membership in try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.userID == old })) { membership.deletedAt = .now } if UserDefaults.standard.string(forKey: userDefaultsKey) == old.uuidString { UserDefaults.standard.set(new.uuidString, forKey: userDefaultsKey) } diff --git a/CalenTaskTests/CalenTaskTests.swift b/CalenTaskTests/CalenTaskTests.swift index 87e0f86..5326056 100644 --- a/CalenTaskTests/CalenTaskTests.swift +++ b/CalenTaskTests/CalenTaskTests.swift @@ -67,6 +67,37 @@ struct DomainModelTests { #expect(teammate.deletedAt == nil) } + /// #12 — `identity` non rifà il seed se spazio e "me" salvati esistono; + /// la fusione dei doppioni "Personale" ripunta anche contatti e crew. + @Test func identityFastPathAndWorkspaceDedupeRepointsEverything() throws { + let container = try makeContainer() + let context = container.mainContext + let (seeded, me) = try SeedService.ensureSeed(in: context) + let fast = try SeedService.identity(in: context) + #expect(fast.workspace.id == seeded.id) + #expect(fast.me.id == me.id) + + let duplicate = Workspace(name: SeedService.personalName, isPersonal: true, colorHex: "#6E56CF") + duplicate.createdAt = seeded.createdAt.addingTimeInterval(60) + context.insert(duplicate) + let task = TodoTask(workspaceID: duplicate.id, title: "Doppione", createdByID: me.id) + let other = TodoTask(workspaceID: seeded.id, title: "Già a posto", createdByID: me.id) + let contact = Contact(workspaceID: duplicate.id, name: "Anna") + context.insert(task) + context.insert(other) + context.insert(contact) + try context.save() + SeedService.select(workspace: duplicate) + + let (resolved, _) = try SeedService.ensureSeed(in: context) + #expect(resolved.id == seeded.id) + #expect(duplicate.deletedAt != nil) + #expect(task.workspaceID == seeded.id) + #expect(other.workspaceID == seeded.id) + #expect(contact.workspaceID == seeded.id) + #expect(try SeedService.identity(in: context).workspace.id == seeded.id) + } + @Test func subtaskCascadeDelete() throws { let container = try makeContainer() let context = container.mainContext