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
30 changes: 15 additions & 15 deletions CalenTask/App/SidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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))
}
}
Expand Down Expand Up @@ -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))
Expand All @@ -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)
Expand Down
11 changes: 4 additions & 7 deletions CalenTask/Features/Browse/BrowseView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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: {
Expand Down
120 changes: 82 additions & 38 deletions CalenTask/Features/SmartLists/SmartListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -34,22 +56,60 @@ extension SavedViewFilters {
struct SmartListView: View {
@Bindable var savedView: SavedView

@Query(filter: #Predicate<TodoTask> { $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<TodoTask> {
$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)
}
}

var body: some View {
let tasks = tasks
Group {
if tasks.isEmpty {
DSEmptyState(
Expand All @@ -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)
}
}
}

Expand Down
9 changes: 8 additions & 1 deletion CalenTask/Support/OpenTaskStats.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 }
}
}
}
}
43 changes: 43 additions & 0 deletions CalenTaskTests/CalenTaskTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading