Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CalenTask/App/AppShellView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
2 changes: 1 addition & 1 deletion CalenTask/Features/Calendar/CalendarActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions CalenTask/Features/Calendar/CalendarScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
)
Expand Down
2 changes: 1 addition & 1 deletion CalenTask/Features/Capture/QuickCaptureView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion CalenTask/Features/Capture/TaskComposerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion CalenTask/Features/Crew/CrewView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion CalenTask/Features/Intake/IntakeFormView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion CalenTask/Features/People/ContactsImportView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CalenTask/Features/Projects/NewProjectSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion CalenTask/Features/Quick/QuickModeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion CalenTask/Features/SmartLists/SmartListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
100 changes: 69 additions & 31 deletions CalenTask/Services/SeedService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<UserProfile>(
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)
}
Expand Down Expand Up @@ -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<TodoTask>())
where task.workspaceID == old { task.workspaceID = new }
for project in try context.fetch(FetchDescriptor<Project>())
where project.workspaceID == old { project.workspaceID = new }
for tag in try context.fetch(FetchDescriptor<Tag>())
where tag.workspaceID == old { tag.workspaceID = new }
for stage in try context.fetch(FetchDescriptor<WorkflowStage>())
where stage.workspaceID == old { stage.workspaceID = new }
for rule in try context.fetch(FetchDescriptor<AutomationRule>())
where rule.workspaceID == old { rule.workspaceID = new }
for field in try context.fetch(FetchDescriptor<CustomFieldDefinition>())
where field.workspaceID == old { field.workspaceID = new }
for value in try context.fetch(FetchDescriptor<CustomFieldValue>())
where value.workspaceID == old { value.workspaceID = new }
for view in try context.fetch(FetchDescriptor<SavedView>())
where view.workspaceID == old { view.workspaceID = new }
for attachment in try context.fetch(FetchDescriptor<Attachment>())
where attachment.workspaceID == old { attachment.workspaceID = new }
for dependency in try context.fetch(FetchDescriptor<TaskDependency>())
where dependency.workspaceID == old { dependency.workspaceID = new }
for membership in try context.fetch(FetchDescriptor<Membership>())
where membership.workspaceID == old { membership.deletedAt = .now }
for task in try context.fetch(FetchDescriptor<TodoTask>(
predicate: #Predicate { $0.workspaceID == old })) { task.workspaceID = new }
for project in try context.fetch(FetchDescriptor<Project>(
predicate: #Predicate { $0.workspaceID == old })) { project.workspaceID = new }
for tag in try context.fetch(FetchDescriptor<Tag>(
predicate: #Predicate { $0.workspaceID == old })) { tag.workspaceID = new }
for stage in try context.fetch(FetchDescriptor<WorkflowStage>(
predicate: #Predicate { $0.workspaceID == old })) { stage.workspaceID = new }
for rule in try context.fetch(FetchDescriptor<AutomationRule>(
predicate: #Predicate { $0.workspaceID == old })) { rule.workspaceID = new }
for field in try context.fetch(FetchDescriptor<CustomFieldDefinition>(
predicate: #Predicate { $0.workspaceID == old })) { field.workspaceID = new }
for value in try context.fetch(FetchDescriptor<CustomFieldValue>(
predicate: #Predicate { $0.workspaceID == old })) { value.workspaceID = new }
for view in try context.fetch(FetchDescriptor<SavedView>(
predicate: #Predicate { $0.workspaceID == old })) { view.workspaceID = new }
for attachment in try context.fetch(FetchDescriptor<Attachment>(
predicate: #Predicate { $0.workspaceID == old })) { attachment.workspaceID = new }
for dependency in try context.fetch(FetchDescriptor<TaskDependency>(
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<Contact>(
predicate: #Predicate { $0.workspaceID == old })) { contact.workspaceID = new }
for crew in try context.fetch(FetchDescriptor<CrewAssignment>(
predicate: #Predicate { $0.workspaceID == old })) { crew.workspaceID = new }
for scene in try context.fetch(FetchDescriptor<ProductionScene>(
predicate: #Predicate { $0.workspaceID == old })) { scene.workspaceID = new }
for membership in try context.fetch(FetchDescriptor<Membership>(
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)
Expand All @@ -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<TodoTask>()) {
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<Project>())
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<TodoTask>(
predicate: #Predicate { $0.createdByID == old })) { task.createdByID = new }
for task in try context.fetch(FetchDescriptor<TodoTask>(
predicate: #Predicate { $0.assigneeID == optionalOld })) { task.assigneeID = new }
for task in try context.fetch(FetchDescriptor<TodoTask>(
predicate: #Predicate { $0.delegatedByID == optionalOld })) { task.delegatedByID = new }
for project in try context.fetch(FetchDescriptor<Project>(
predicate: #Predicate { $0.createdByID == old })) { project.createdByID = new }
// Le regole sono poche: filtrarle in memoria va bene.
for rule in try context.fetch(FetchDescriptor<AutomationRule>()) {
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<Membership>())
where membership.userID == old { membership.deletedAt = .now }
for membership in try context.fetch(FetchDescriptor<Membership>(
predicate: #Predicate { $0.userID == old })) { membership.deletedAt = .now }
if UserDefaults.standard.string(forKey: userDefaultsKey) == old.uuidString {
UserDefaults.standard.set(new.uuidString, forKey: userDefaultsKey)
}
Expand Down
31 changes: 31 additions & 0 deletions CalenTaskTests/CalenTaskTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading