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
2 changes: 1 addition & 1 deletion CalenTask/App/AppShellView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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>) -> [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))
Expand Down
12 changes: 10 additions & 2 deletions CalenTask/DesignSystem/Components/TaskContextMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
4 changes: 2 additions & 2 deletions CalenTask/Features/Calendar/CalendarActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<TodoTask>(predicate: #Predicate { $0.id == id })
).first,
let anchor = task.startAt ?? task.dueAt
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions CalenTask/Features/Capture/CaptureAssistView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Project>(
let projects = context.fetchOrLog(FetchDescriptor<Project>(
predicate: #Predicate { $0.deletedAt == nil }
))) ?? []
))
return projects.first { $0.name.localizedCaseInsensitiveContains(query) }
}
}
Expand Down
2 changes: 1 addition & 1 deletion CalenTask/Features/People/PersonDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ struct PersonDetailView: View {
let descriptor = FetchDescriptor<TodoTask>(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) }
}
Expand Down
2 changes: 1 addition & 1 deletion CalenTask/Features/Production/ProjectStripboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ struct SceneEditorView: View {
created.shootDayID = shootDayID
modelContext.insert(created)
}
try? modelContext.save()
modelContext.saveOrLog()
dismiss()
}
}
Expand Down
2 changes: 1 addition & 1 deletion CalenTask/Features/Production/ShootDayEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ struct ShootDayEditorView: View {
ToolbarItem(placement: .confirmationAction) {
Button("Fine") {
shootDay.touch()
try? modelContext.save()
modelContext.saveOrLog()
dismiss()
}
}
Expand Down
2 changes: 1 addition & 1 deletion CalenTask/Models/TodoTask+Mutations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions CalenTask/Services/AutomationEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserProfile>(
let person = context.fetchOrLog(FetchDescriptor<UserProfile>(
predicate: #Predicate { $0.id == notifyID }
)).first
NotificationService.shared.postAutomationNotice(
Expand All @@ -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 }
}
}
32 changes: 17 additions & 15 deletions CalenTask/Services/ConflictService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -12,31 +12,33 @@ enum ConflictService {
contactID: UUID, day: Date, excludingTaskID taskID: UUID,
in context: ModelContext
) -> [TodoTask] {
let assignments = (try? context.fetch(FetchDescriptor<CrewAssignment>(
let assignments = context.fetchOrLog(FetchDescriptor<CrewAssignment>(
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<TodoTask>(
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<TodoTask>(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.
static func conflictedContactIDs(
for shootDay: TodoTask, in context: ModelContext
) -> Set<UUID> {
guard let day = shootDay.startAt else { return [] }
let assignments = (try? context.fetch(FetchDescriptor<CrewAssignment>(
predicate: #Predicate { $0.deletedAt == nil }
))) ?? []
let mine = assignments.filter { $0.taskID == shootDay.id }
let shootDayID = shootDay.id
let mine = context.fetchOrLog(FetchDescriptor<CrewAssignment>(
predicate: #Predicate { $0.deletedAt == nil && $0.taskID == shootDayID }
))
var conflicted: Set<UUID> = []
for assignment in mine where !conflicts(
contactID: assignment.contactID, day: day,
Expand Down
2 changes: 1 addition & 1 deletion CalenTask/Services/ContactsImportService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ enum ContactsImportService {
return false
}
update(contact, from: deviceContact)
try? context.save()
context.saveOrLog()
return true
}
}
35 changes: 28 additions & 7 deletions CalenTask/Services/NotificationService.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import os
import UserNotifications
import Observation
import SwiftData
Expand Down Expand Up @@ -44,9 +45,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<TodoTask>(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<TodoTask>(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) }
Expand All @@ -64,7 +70,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)
}
}
}
Expand All @@ -80,12 +86,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
Expand Down Expand Up @@ -130,7 +151,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)
}
}

Expand Down Expand Up @@ -274,7 +295,7 @@ final class NotificationService {
)
Task {
await requestAuthorizationIfNeeded()
try? await center.add(request)
await schedule(request)
}
}
}
39 changes: 39 additions & 0 deletions CalenTask/Support/ModelContext+Logging.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Foundation
import os
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<T: PersistentModel>(
_ descriptor: FetchDescriptor<T>, 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 []
}
}
}
34 changes: 34 additions & 0 deletions CalenTaskTests/CalenTaskTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading