Skip to content

Commit 3bd2153

Browse files
Engagendyclaude
andcommitted
Add 10 features: CSV export, dark mode, timeline, custom fields, print, diff, search, keyboard nav, pinch-zoom, flagged tasks
Medium-impact features: - CSV export from task table with full hierarchy - Dark mode polish across Gantt, header, workload views with adaptive opacities - Timeline view with colored phase ribbons, milestones, pre-computed Canvas data - Custom fields display (text/number/cost/flag/date) in detail view and table columns - Print support via native NSPrintOperation for tasks and Gantt Nice-to-have features: - Diff two MPP versions with add/remove/modify detection and color-coded table - Search bar with suggestions that navigate to matched tasks - Keyboard shortcuts (Cmd+1-9) for sidebar navigation - Pinch-to-zoom (MagnifyGesture) on Gantt and Workload views - Bookmark/flag tasks with persistence via AppStorage Bug fixes: - Calendar parent chain resolution for resource working days (default type inheritance) - Case-insensitive resource type comparison (WORK vs work) - Assignment units displayed as percentage correctly (no double multiplication) - Resource workload uses actual project calendars, exceptions, and working hours - Pre-computed data for Timeline/Workload/Resource views to eliminate rendering lag - Today line in workload canvas spans full height - DiffView uses .fileImporter instead of NSOpenPanel for sandbox compatibility - PDF export forces light appearance to avoid dark-mode artifacts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fac3779 commit 3bd2153

26 files changed

Lines changed: 2797 additions & 54 deletions

MPPViewer/MPPViewer.xcodeproj/project.pbxproj

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@
3434
A10000010000000000000030 /* MilestonesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000010000000000000032 /* MilestonesView.swift */; };
3535
A10000010000000000000050 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000010000000000000052 /* DashboardView.swift */; };
3636
A10000010000000000000040 /* MPPConverterProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000010000000000000040 /* MPPConverterProtocol.swift */; };
37+
A10000010000000000000080 /* TaskFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000010000000000000080 /* TaskFilter.swift */; };
38+
A10000010000000000000081 /* EVMCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000010000000000000081 /* EVMCalculator.swift */; };
39+
A10000010000000000000082 /* WorkloadCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000010000000000000082 /* WorkloadCalculator.swift */; };
40+
A10000010000000000000083 /* FilterBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000010000000000000083 /* FilterBarView.swift */; };
41+
A10000010000000000000084 /* EarnedValueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000010000000000000084 /* EarnedValueView.swift */; };
42+
A10000010000000000000085 /* WorkloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000010000000000000085 /* WorkloadView.swift */; };
43+
A10000010000000000000090 /* CSVExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000010000000000000090 /* CSVExporter.swift */; };
44+
A10000010000000000000091 /* AnyCodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000010000000000000091 /* AnyCodable.swift */; };
45+
A10000010000000000000092 /* PrintManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000010000000000000092 /* PrintManager.swift */; };
46+
A10000010000000000000093 /* ProjectDiff.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000010000000000000093 /* ProjectDiff.swift */; };
47+
A10000010000000000000094 /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000010000000000000094 /* TimelineView.swift */; };
48+
A10000010000000000000095 /* DiffView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000010000000000000095 /* DiffView.swift */; };
3749
/* End PBXBuildFile section */
3850

3951
/* Begin PBXContainerItemProxy section */
@@ -90,6 +102,18 @@
90102
A20000010000000000000032 /* MilestonesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MilestonesView.swift; sourceTree = "<group>"; };
91103
A20000010000000000000052 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
92104
A20000010000000000000040 /* MPPConverterProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MPPConverterProtocol.swift; sourceTree = "<group>"; };
105+
A20000010000000000000080 /* TaskFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskFilter.swift; sourceTree = "<group>"; };
106+
A20000010000000000000081 /* EVMCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EVMCalculator.swift; sourceTree = "<group>"; };
107+
A20000010000000000000082 /* WorkloadCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkloadCalculator.swift; sourceTree = "<group>"; };
108+
A20000010000000000000083 /* FilterBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterBarView.swift; sourceTree = "<group>"; };
109+
A20000010000000000000084 /* EarnedValueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EarnedValueView.swift; sourceTree = "<group>"; };
110+
A20000010000000000000085 /* WorkloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkloadView.swift; sourceTree = "<group>"; };
111+
A20000010000000000000090 /* CSVExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVExporter.swift; sourceTree = "<group>"; };
112+
A20000010000000000000091 /* AnyCodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyCodable.swift; sourceTree = "<group>"; };
113+
A20000010000000000000092 /* PrintManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrintManager.swift; sourceTree = "<group>"; };
114+
A20000010000000000000093 /* ProjectDiff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectDiff.swift; sourceTree = "<group>"; };
115+
A20000010000000000000094 /* TimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = "<group>"; };
116+
A20000010000000000000095 /* DiffView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffView.swift; sourceTree = "<group>"; };
93117
A30000010000000000000001 /* MPPViewer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MPPViewer.app; sourceTree = BUILT_PRODUCTS_DIR; };
94118
/* End PBXFileReference section */
95119

@@ -164,6 +188,9 @@
164188
children = (
165189
A20000010000000000000004 /* ProjectModel.swift */,
166190
A20000010000000000000005 /* ProjectStore.swift */,
191+
A20000010000000000000080 /* TaskFilter.swift */,
192+
A20000010000000000000091 /* AnyCodable.swift */,
193+
A20000010000000000000093 /* ProjectDiff.swift */,
167194
);
168195
path = Models;
169196
sourceTree = "<group>";
@@ -174,6 +201,8 @@
174201
A20000010000000000000006 /* MPPConverterService.swift */,
175202
A20000010000000000000007 /* JSONProjectParser.swift */,
176203
A20000010000000000000040 /* MPPConverterProtocol.swift */,
204+
A20000010000000000000081 /* EVMCalculator.swift */,
205+
A20000010000000000000082 /* WorkloadCalculator.swift */,
177206
);
178207
path = Services;
179208
sourceTree = "<group>";
@@ -190,6 +219,11 @@
190219
A50000010000000000000014 /* Schedule */,
191220
A50000010000000000000024 /* Milestones */,
192221
A50000010000000000000034 /* Dashboard */,
222+
A50000010000000000000080 /* Components */,
223+
A50000010000000000000081 /* EarnedValue */,
224+
A50000010000000000000082 /* Workload */,
225+
A50000010000000000000090 /* Timeline */,
226+
A50000010000000000000091 /* Diff */,
193227
);
194228
path = Views;
195229
sourceTree = "<group>";
@@ -226,6 +260,8 @@
226260
A20000010000000000000018 /* DurationFormatting.swift */,
227261
A20000010000000000000019 /* ColorTheme.swift */,
228262
A20000010000000000000070 /* PDFExporter.swift */,
263+
A20000010000000000000090 /* CSVExporter.swift */,
264+
A20000010000000000000092 /* PrintManager.swift */,
229265
);
230266
path = Utilities;
231267
sourceTree = "<group>";
@@ -281,6 +317,46 @@
281317
path = Dashboard;
282318
sourceTree = "<group>";
283319
};
320+
A50000010000000000000080 /* Components */ = {
321+
isa = PBXGroup;
322+
children = (
323+
A20000010000000000000083 /* FilterBarView.swift */,
324+
);
325+
path = Components;
326+
sourceTree = "<group>";
327+
};
328+
A50000010000000000000081 /* EarnedValue */ = {
329+
isa = PBXGroup;
330+
children = (
331+
A20000010000000000000084 /* EarnedValueView.swift */,
332+
);
333+
path = EarnedValue;
334+
sourceTree = "<group>";
335+
};
336+
A50000010000000000000082 /* Workload */ = {
337+
isa = PBXGroup;
338+
children = (
339+
A20000010000000000000085 /* WorkloadView.swift */,
340+
);
341+
path = Workload;
342+
sourceTree = "<group>";
343+
};
344+
A50000010000000000000090 /* Timeline */ = {
345+
isa = PBXGroup;
346+
children = (
347+
A20000010000000000000094 /* TimelineView.swift */,
348+
);
349+
path = Timeline;
350+
sourceTree = "<group>";
351+
};
352+
A50000010000000000000091 /* Diff */ = {
353+
isa = PBXGroup;
354+
children = (
355+
A20000010000000000000095 /* DiffView.swift */,
356+
);
357+
path = Diff;
358+
sourceTree = "<group>";
359+
};
284360
A50000010000000000000099 /* Products */ = {
285361
isa = PBXGroup;
286362
children = (
@@ -426,6 +502,18 @@
426502
A10000010000000000000030 /* MilestonesView.swift in Sources */,
427503
A10000010000000000000050 /* DashboardView.swift in Sources */,
428504
A10000010000000000000040 /* MPPConverterProtocol.swift in Sources */,
505+
A10000010000000000000080 /* TaskFilter.swift in Sources */,
506+
A10000010000000000000081 /* EVMCalculator.swift in Sources */,
507+
A10000010000000000000082 /* WorkloadCalculator.swift in Sources */,
508+
A10000010000000000000083 /* FilterBarView.swift in Sources */,
509+
A10000010000000000000084 /* EarnedValueView.swift in Sources */,
510+
A10000010000000000000085 /* WorkloadView.swift in Sources */,
511+
A10000010000000000000090 /* CSVExporter.swift in Sources */,
512+
A10000010000000000000091 /* AnyCodable.swift in Sources */,
513+
A10000010000000000000092 /* PrintManager.swift in Sources */,
514+
A10000010000000000000093 /* ProjectDiff.swift in Sources */,
515+
A10000010000000000000094 /* TimelineView.swift in Sources */,
516+
A10000010000000000000095 /* DiffView.swift in Sources */,
429517
);
430518
runOnlyForDeploymentPostprocessing = 0;
431519
};

MPPViewer/MPPViewer/App/ContentView.swift

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
import SwiftUI
2+
import Combine
3+
4+
extension Notification.Name {
5+
static let navigateToItem = Notification.Name("navigateToItem")
6+
}
27

38
enum NavigationItem: String, CaseIterable, Identifiable {
49
case dashboard = "Dashboard"
@@ -8,7 +13,11 @@ enum NavigationItem: String, CaseIterable, Identifiable {
813
case schedule = "Schedule"
914
case milestones = "Milestones"
1015
case resources = "Resources"
16+
case earnedValue = "Earned Value"
17+
case workload = "Workload"
1118
case calendar = "Calendar"
19+
case timeline = "Timeline"
20+
case diff = "Compare"
1221

1322
var id: String { rawValue }
1423

@@ -21,7 +30,11 @@ enum NavigationItem: String, CaseIterable, Identifiable {
2130
case .schedule: return "rectangle.split.2x1"
2231
case .milestones: return "diamond.fill"
2332
case .resources: return "person.2"
33+
case .earnedValue: return "chart.line.uptrend.xyaxis"
34+
case .workload: return "person.badge.clock"
2435
case .calendar: return "calendar"
36+
case .timeline: return "rectangle.split.3x1"
37+
case .diff: return "arrow.triangle.2.circlepath"
2538
}
2639
}
2740
}
@@ -31,6 +44,31 @@ struct ContentView: View {
3144
@StateObject private var store = ProjectStore()
3245
@State private var selectedNav: NavigationItem? = .dashboard
3346
@State private var searchText = ""
47+
@State private var navigateToTaskID: Int?
48+
@AppStorage("flaggedTaskIDs") private var flaggedTaskIDsData: Data = Data()
49+
50+
private var flaggedTaskIDs: Binding<Set<Int>> {
51+
Binding(
52+
get: {
53+
(try? JSONDecoder().decode(Set<Int>.self, from: flaggedTaskIDsData)) ?? []
54+
},
55+
set: { newValue in
56+
flaggedTaskIDsData = (try? JSONEncoder().encode(newValue)) ?? Data()
57+
}
58+
)
59+
}
60+
61+
private var searchSuggestionTasks: [ProjectTask] {
62+
guard let project = store.project, !searchText.isEmpty else { return [] }
63+
let search = searchText.lowercased()
64+
return project.tasks.filter { task in
65+
task.name?.lowercased().contains(search) == true ||
66+
task.wbs?.lowercased().contains(search) == true ||
67+
task.notes?.lowercased().contains(search) == true
68+
}
69+
.prefix(10)
70+
.map { $0 }
71+
}
3472

3573
var body: some View {
3674
NavigationSplitView {
@@ -66,11 +104,46 @@ struct ContentView: View {
66104
}
67105
.frame(maxWidth: .infinity, maxHeight: .infinity)
68106
}
69-
.searchable(text: $searchText, prompt: "Filter tasks by name")
107+
.searchable(text: $searchText, prompt: "Search tasks by name, WBS, or notes")
108+
.searchSuggestions {
109+
ForEach(searchSuggestionTasks) { task in
110+
Button {
111+
selectedNav = .tasks
112+
navigateToTaskID = task.uniqueID
113+
searchText = ""
114+
} label: {
115+
HStack {
116+
if task.milestone == true {
117+
Image(systemName: "diamond.fill")
118+
.font(.caption2)
119+
.foregroundStyle(.orange)
120+
} else if task.summary == true {
121+
Image(systemName: "folder.fill")
122+
.font(.caption2)
123+
.foregroundStyle(.blue)
124+
}
125+
VStack(alignment: .leading) {
126+
Text(task.displayName)
127+
.font(.caption)
128+
if let wbs = task.wbs {
129+
Text(wbs)
130+
.font(.caption2)
131+
.foregroundStyle(.secondary)
132+
}
133+
}
134+
}
135+
}
136+
}
137+
}
70138
.navigationTitle(store.project?.properties.projectTitle ?? "MPP Viewer")
71139
.task {
72140
await store.loadFromDocument(document)
73141
}
142+
.onReceive(NotificationCenter.default.publisher(for: .navigateToItem)) { notification in
143+
if let item = notification.object as? NavigationItem {
144+
selectedNav = item
145+
}
146+
}
74147
}
75148

76149
@ViewBuilder
@@ -81,7 +154,15 @@ struct ContentView: View {
81154
case .summary:
82155
ProjectSummaryView(project: project)
83156
case .tasks:
84-
TaskTableView(tasks: project.rootTasks, allTasks: project.tasksByID, searchText: searchText, resources: project.resources, assignments: project.assignments)
157+
TaskTableView(
158+
tasks: project.rootTasks,
159+
allTasks: project.tasksByID,
160+
searchText: searchText,
161+
resources: project.resources,
162+
assignments: project.assignments,
163+
flaggedTaskIDs: flaggedTaskIDs,
164+
navigateToTaskID: $navigateToTaskID
165+
)
85166
case .gantt:
86167
GanttChartView(project: project, searchText: searchText)
87168
case .schedule:
@@ -90,8 +171,16 @@ struct ContentView: View {
90171
MilestonesView(tasks: project.tasks, allTasks: project.tasksByID, searchText: searchText)
91172
case .resources:
92173
ResourceSheetView(resources: project.resources, assignments: project.assignments)
174+
case .earnedValue:
175+
EarnedValueView(project: project)
176+
case .workload:
177+
WorkloadView(project: project)
93178
case .calendar:
94179
CalendarView(calendars: project.calendars)
180+
case .timeline:
181+
TimelineView(project: project)
182+
case .diff:
183+
DiffView(project: project)
95184
case .none:
96185
Text("Select a view from the sidebar")
97186
.foregroundStyle(.secondary)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,36 @@
11
import SwiftUI
22
import UniformTypeIdentifiers
3+
import AppKit
4+
5+
class AppDelegate: NSObject, NSApplicationDelegate {
6+
func applicationDidFinishLaunching(_ notification: Notification) {
7+
NSWindow.allowsAutomaticWindowTabbing = true
8+
}
9+
}
310

411
@main
512
struct MPPViewerApp: App {
13+
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
14+
615
var body: some Scene {
716
DocumentGroup(viewing: MPPDocument.self) { file in
817
ContentView(document: file.document)
918
}
1019
.commands {
1120
CommandGroup(replacing: .newItem) {}
21+
CommandGroup(after: .sidebar) {
22+
ForEach(Array(NavigationItem.allCases.enumerated()), id: \.element.id) { index, item in
23+
if index < 9 {
24+
Button(item.rawValue) {
25+
NotificationCenter.default.post(
26+
name: .navigateToItem,
27+
object: item
28+
)
29+
}
30+
.keyboardShortcut(KeyEquivalent(Character("\(index + 1)")), modifiers: .command)
31+
}
32+
}
33+
}
1234
}
1335
}
1436
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import Foundation
2+
3+
struct AnyCodable: Codable {
4+
let value: Any
5+
6+
var displayString: String {
7+
switch value {
8+
case let b as Bool:
9+
return b ? "Yes" : "No"
10+
case let i as Int:
11+
return String(i)
12+
case let d as Double:
13+
return String(format: "%.2f", d)
14+
case let s as String:
15+
return s
16+
default:
17+
return "\(value)"
18+
}
19+
}
20+
21+
init(_ value: Any) {
22+
self.value = value
23+
}
24+
25+
init(from decoder: Decoder) throws {
26+
let container = try decoder.singleValueContainer()
27+
if let b = try? container.decode(Bool.self) {
28+
value = b
29+
} else if let i = try? container.decode(Int.self) {
30+
value = i
31+
} else if let d = try? container.decode(Double.self) {
32+
value = d
33+
} else if let s = try? container.decode(String.self) {
34+
value = s
35+
} else {
36+
value = ""
37+
}
38+
}
39+
40+
func encode(to encoder: Encoder) throws {
41+
var container = encoder.singleValueContainer()
42+
switch value {
43+
case let b as Bool:
44+
try container.encode(b)
45+
case let i as Int:
46+
try container.encode(i)
47+
case let d as Double:
48+
try container.encode(d)
49+
case let s as String:
50+
try container.encode(s)
51+
default:
52+
try container.encode("\(value)")
53+
}
54+
}
55+
}

0 commit comments

Comments
 (0)