diff --git a/CalenTask/App/SidebarView.swift b/CalenTask/App/SidebarView.swift index 46e3706..8f32798 100644 --- a/CalenTask/App/SidebarView.swift +++ b/CalenTask/App/SidebarView.swift @@ -90,7 +90,9 @@ private struct SidebarContent: View { } var body: some View { - let stats = OpenTaskStats(tasks: openTasks, calendar: .app, now: today) + let showsTagCounts = configuration.isEnabled(.tags) && showsTags + let stats = OpenTaskStats(tasks: openTasks, calendar: .app, now: today, + countsTags: showsTagCounts) let openCounts = stats.byProject let scopedProjects = projects let favoriteProjects = scopedProjects.filter(\.isFavorite) @@ -160,13 +162,16 @@ private struct SidebarContent: View { } if configuration.isEnabled(.smartLists) { + let scopedLists = smartLists Section { if showsLists { - ForEach(smartLists, id: \.id) { list in - smartListRow(list) + // #11 — conteggi in un passaggio, non uno per riga. + let listCounts = SavedViewFilters.counts(for: scopedLists, in: openTasks, now: today) + ForEach(scopedLists, id: \.id) { list in + smartListRow(list, count: listCounts[list.id] ?? 0) .tag(AppDestination.smartList(list.id)) } - if smartLists.isEmpty { + if scopedLists.isEmpty { SidebarPlaceholderRow( title: "Nuova lista smart", systemImage: "plus.circle" @@ -176,7 +181,7 @@ private struct SidebarContent: View { } header: { SidebarGroupHeader( title: "Liste", - count: smartLists.count, + count: scopedLists.count, isExpanded: $showsLists, accessory: SidebarGroupHeader.Accessory(systemImage: "plus", help: "Nuova lista") { isCreatingSmartList = true @@ -190,7 +195,7 @@ private struct SidebarContent: View { Section { if showsTags { ForEach(tags, id: \.id) { tag in - tagRow(tag) + tagRow(tag, count: stats.byTag[tag.id] ?? 0) .tag(AppDestination.tag(tag.id)) } } @@ -326,9 +331,8 @@ private struct SidebarContent: View { } } - private func tagRow(_ tag: Tag) -> some View { - let count = tag.tasks.filter { $0.deletedAt == nil && !$0.isDone && !$0.isTemplate }.count - return HStack(spacing: DS.s) { + private func tagRow(_ tag: Tag, count: Int) -> some View { + HStack(spacing: DS.s) { Image(systemName: "number") .font(.system(size: 12, weight: .bold)) .foregroundStyle(Color(hex: tag.colorHex)) @@ -343,12 +347,8 @@ private struct SidebarContent: View { .contentShape(Rectangle()) } - private func smartListRow(_ list: SavedView) -> some View { - let filters = list.filters - let count = openTasks - .filter { $0.workspaceID == list.workspaceID && filters.matches($0) } - .count - return HStack(spacing: DS.s) { + private func smartListRow(_ list: SavedView, count: Int) -> some View { + HStack(spacing: DS.s) { Image(systemName: "line.3.horizontal.decrease.circle.fill") .font(.system(size: 14)) .foregroundStyle(Color.accentColor) diff --git a/CalenTask/Features/Browse/BrowseView.swift b/CalenTask/Features/Browse/BrowseView.swift index 034ca7e..b4f55bf 100644 --- a/CalenTask/Features/Browse/BrowseView.swift +++ b/CalenTask/Features/Browse/BrowseView.swift @@ -274,9 +274,10 @@ private struct BrowseSmartListRows: View { } var body: some View { + let counts = SavedViewFilters.counts(for: lists, in: openTasks) ForEach(lists, id: \.id) { list in NavigationLink(value: AppDestination.smartList(list.id)) { - row(list) + row(list, count: counts[list.id] ?? 0) } .contextMenu { Button(role: .destructive) { @@ -291,12 +292,8 @@ private struct BrowseSmartListRows: View { } } - private func row(_ list: SavedView) -> some View { - let filters = list.filters - let count = openTasks - .filter { $0.workspaceID == list.workspaceID && filters.matches($0) } - .count - return Label { + private func row(_ list: SavedView, count: Int) -> some View { + Label { Text(list.name) .font(.dsMeta.weight(.medium)) } icon: { diff --git a/CalenTask/Features/SmartLists/SmartListView.swift b/CalenTask/Features/SmartLists/SmartListView.swift index f60fd4a..61a632e 100644 --- a/CalenTask/Features/SmartLists/SmartListView.swift +++ b/CalenTask/Features/SmartLists/SmartListView.swift @@ -5,24 +5,46 @@ import SwiftData extension SavedViewFilters { /// Tutti i criteri in AND; array vuoti = nessun vincolo. - func matches(_ task: TodoTask) -> Bool { - guard task.deletedAt == nil, !task.isTemplate, !task.isPhase else { return false } - if !includeDone && task.isDone { return false } - if !statuses.isEmpty && !statuses.contains(task.status) { return false } - if !priorities.isEmpty && !priorities.contains(task.priority) { return false } - if !kinds.isEmpty && !kinds.contains(task.kind) { return false } - if !tagIDs.isEmpty { - let taskTagIDs = Set(task.tags.filter { $0.deletedAt == nil }.map(\.id)) - if !tagIDs.contains(where: taskTagIDs.contains) { return false } + func matches(_ task: TodoTask, now: Date = .now) -> Bool { + matcher(now: now)(task) + } + + /// #11 — Il filtro pronto per molte attività: il limite della scadenza + /// si calcola una volta, non una per attività. + func matcher(now: Date = .now) -> (TodoTask) -> Bool { + let dueLimit = dueWithinDays.flatMap { + Calendar.app.date(byAdding: .day, value: $0 + 1, to: Calendar.app.startOfDay(for: now)) } - if let days = dueWithinDays { - guard let dueAt = task.dueAt, - let limit = Calendar.app.date( - byAdding: .day, value: days + 1, to: .now.startOfDay) - else { return false } - if dueAt >= limit { return false } + return { task in + guard task.deletedAt == nil, !task.isTemplate, !task.isPhase else { return false } + if !includeDone && task.isDone { return false } + if !statuses.isEmpty && !statuses.contains(task.status) { return false } + if !priorities.isEmpty && !priorities.contains(task.priority) { return false } + if !kinds.isEmpty && !kinds.contains(task.kind) { return false } + if !tagIDs.isEmpty { + let taskTagIDs = Set(task.tags.filter { $0.deletedAt == nil }.map(\.id)) + if !tagIDs.contains(where: taskTagIDs.contains) { return false } + } + if dueWithinDays != nil { + guard let dueAt = task.dueAt, let dueLimit else { return false } + if dueAt >= dueLimit { return false } + } + return true + } + } + + /// #11 — Badge delle liste smart in un passaggio: le attività divise per + /// spazio una volta sola, ogni lista scorre solo quelle del suo spazio. + static func counts(for lists: [SavedView], in tasks: [TodoTask], + now: Date = .now) -> [UUID: Int] { + guard !lists.isEmpty else { return [:] } + let byWorkspace = Dictionary(grouping: tasks, by: \.workspaceID) + var counts: [UUID: Int] = [:] + for list in lists { + let matches = list.filters.matcher(now: now) + counts[list.id] = byWorkspace[list.workspaceID]?.count(where: matches) ?? 0 } - return true + return counts } } @@ -34,15 +56,52 @@ extension SavedViewFilters { struct SmartListView: View { @Bindable var savedView: SavedView - @Query(filter: #Predicate { $0.deletedAt == nil && !$0.isTemplate }) - private var allTasks: [TodoTask] - @State private var isEditing = false + var body: some View { + // #5 — dallo store solo lo spazio della lista (e solo le aperte se + // la lista non include le completate); il resto dei filtri in memoria. + SmartListTasks( + filters: savedView.filters, + workspaceID: savedView.workspaceID + ) + .navigationTitle(savedView.name) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem { + Button { + isEditing = true + } label: { + Label("Modifica filtri", systemImage: "slider.horizontal.3") + } + } + } + .sheet(isPresented: $isEditing) { + SmartListEditorView(savedView: savedView) + } + } +} + +private struct SmartListTasks: View { + let filters: SavedViewFilters + @Query private var candidates: [TodoTask] + + init(filters: SavedViewFilters, workspaceID: UUID) { + self.filters = filters + if filters.includeDone { + _candidates = Query(filter: #Predicate { + $0.workspaceID == workspaceID && $0.deletedAt == nil && !$0.isTemplate + }) + } else { + _candidates = Query(filter: TodoTask.openPredicate(workspaceID: workspaceID)) + } + } + private var tasks: [TodoTask] { - let filters = savedView.filters - return allTasks - .filter { $0.workspaceID == savedView.workspaceID && filters.matches($0) } + candidates + .filter(filters.matcher()) .sorted { ($0.dueAt ?? .distantFuture, $1.priorityRaw) < ($1.dueAt ?? .distantFuture, $0.priorityRaw) @@ -50,6 +109,7 @@ struct SmartListView: View { } var body: some View { + let tasks = tasks Group { if tasks.isEmpty { DSEmptyState( @@ -70,22 +130,6 @@ struct SmartListView: View { } } } - .navigationTitle(savedView.name) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem { - Button { - isEditing = true - } label: { - Label("Modifica filtri", systemImage: "slider.horizontal.3") - } - } - } - .sheet(isPresented: $isEditing) { - SmartListEditorView(savedView: savedView) - } } } diff --git a/CalenTask/Support/OpenTaskStats.swift b/CalenTask/Support/OpenTaskStats.swift index b838577..fb7f7df 100644 --- a/CalenTask/Support/OpenTaskStats.swift +++ b/CalenTask/Support/OpenTaskStats.swift @@ -12,8 +12,12 @@ struct OpenTaskStats { private(set) var todayEvents = 0 /// Giornate con attività (inizio o scadenza) → pallini del mini-mese. private(set) var byDay: [Date: Int] = [:] + /// #11 — Aperte per etichetta, solo se richieste (`countsTags`): leggere + /// le etichette delle sole aperte invece di `tag.tasks`, che carica anche + /// tutte le completate di ogni etichetta. + private(set) var byTag: [UUID: Int] = [:] - init(tasks: [TodoTask], calendar: Calendar, now: Date = .now) { + init(tasks: [TodoTask], calendar: Calendar, now: Date = .now, countsTags: Bool = false) { let todayStart = calendar.startOfDay(for: now) let tomorrowStart = calendar.date(byAdding: .day, value: 1, to: todayStart) ?? todayStart func isToday(_ date: Date?) -> Bool { @@ -29,6 +33,9 @@ struct OpenTaskStats { if task.kind == .event, isToday(start) { todayEvents += 1 } if let start { byDay[calendar.startOfDay(for: start), default: 0] += 1 } if let due { byDay[calendar.startOfDay(for: due), default: 0] += 1 } + if countsTags { + for tag in task.tags where tag.deletedAt == nil { byTag[tag.id, default: 0] += 1 } + } } } } diff --git a/CalenTaskTests/CalenTaskTests.swift b/CalenTaskTests/CalenTaskTests.swift index b7853b6..87e0f86 100644 --- a/CalenTaskTests/CalenTaskTests.swift +++ b/CalenTaskTests/CalenTaskTests.swift @@ -915,6 +915,49 @@ struct DomainModelTests { #expect(stats.byDay[calendar.startOfDay(for: at(1))] == 2) } + /// #11 — badge della sidebar: etichette contate sulle sole aperte (non + /// con `tag.tasks`), liste smart contate in un passaggio per spazio. + @Test func sidebarTagAndSmartListCounts() throws { + let container = try makeContainer() + let context = container.mainContext + let (workspace, me) = try SeedService.ensureSeed(in: context) + let urgent = CalenTask.Tag(workspaceID: workspace.id, name: "urgente") + let removed = CalenTask.Tag(workspaceID: workspace.id, name: "vecchia") + removed.deletedAt = .now + context.insert(urgent) + context.insert(removed) + @discardableResult + func add(_ title: String, status: TaskStatus = .todo, priority: TaskPriority = .normal, + tags: [CalenTask.Tag] = [], workspaceID: UUID? = nil) -> TodoTask { + let task = TodoTask(workspaceID: workspaceID ?? workspace.id, title: title, + status: status, priority: priority, createdByID: me.id) + context.insert(task) + task.tags = tags + return task + } + add("Aperta urgente", priority: .urgent, tags: [urgent, removed]) + add("Aperta", tags: [urgent]) + add("Fatta", status: .done, priority: .urgent, tags: [urgent]) + add("Altro spazio", priority: .urgent, workspaceID: UUID()) + try context.save() + + let open = try context.fetch(FetchDescriptor(predicate: TodoTask.openPredicate(workspaceID: nil))) + let stats = OpenTaskStats(tasks: open, calendar: .app, countsTags: true) + #expect(stats.byTag[urgent.id] == 2) + #expect(stats.byTag[removed.id] == nil) + #expect(OpenTaskStats(tasks: open, calendar: .app).byTag.isEmpty) + + var filters = SavedViewFilters() + filters.priorities = [.urgent] + let list = SavedView(workspaceID: workspace.id, name: "Urgenti", viewType: .list, filters: filters) + let everything = SavedView(workspaceID: workspace.id, name: "Tutte", viewType: .list) + context.insert(list) + context.insert(everything) + let counts = SavedViewFilters.counts(for: [list, everything], in: open) + #expect(counts[list.id] == 1) + #expect(counts[everything.id] == 2) + } + /// Inizio e fine restano in ordine: una fine prima dell'inizio mandava /// in crash l'agenda del giorno (range rovesciato). @Test func startAndEndStayOrdered() throws {