Explorar el Código

Persist saved jobs and add Saved Jobs page

Make JobListing Codable for JSON storage. Add SavedJobsStore using
UserDefaults. Dashboard loads saved order on launch, shows a dedicated
Saved Jobs sidebar view, and refactors listing card layout for both
home results and saved list.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 hace 3 semanas
padre
commit
6de812b12d

+ 1 - 1
App for Indeed/Models/DashboardModels.swift

@@ -11,7 +11,7 @@ struct SidebarItem {
11
     let badge: String?
11
     let badge: String?
12
 }
12
 }
13
 
13
 
14
-struct JobListing: Hashable {
14
+struct JobListing: Hashable, Codable {
15
     let title: String
15
     let title: String
16
     let description: String
16
     let description: String
17
 }
17
 }

+ 28 - 0
App for Indeed/Models/SavedJobsStore.swift

@@ -0,0 +1,28 @@
1
+//
2
+//  SavedJobsStore.swift
3
+//  App for Indeed
4
+//
5
+
6
+import Foundation
7
+
8
+enum SavedJobsStore {
9
+    private static let defaultsKey = "com.appforindeed.savedJobListings.v1"
10
+
11
+    static func load() -> [JobListing] {
12
+        guard let data = UserDefaults.standard.data(forKey: defaultsKey) else { return [] }
13
+        do {
14
+            return try JSONDecoder().decode([JobListing].self, from: data)
15
+        } catch {
16
+            return []
17
+        }
18
+    }
19
+
20
+    static func save(_ jobs: [JobListing]) {
21
+        do {
22
+            let data = try JSONEncoder().encode(jobs)
23
+            UserDefaults.standard.set(data, forKey: defaultsKey)
24
+        } catch {
25
+            // Best-effort persistence; UI state remains in memory for this session.
26
+        }
27
+    }
28
+}

+ 202 - 31
App for Indeed/Views/DashboardView.swift

@@ -6,6 +6,11 @@
6
 import Cocoa
6
 import Cocoa
7
 import QuartzCore
7
 import QuartzCore
8
 
8
 
9
+private enum JobListingCardContext {
10
+    case homeSearchResults
11
+    case savedJobsPage
12
+}
13
+
9
 final class DashboardView: NSView, NSTextFieldDelegate {
14
 final class DashboardView: NSView, NSTextFieldDelegate {
10
     /// Indeed.com-inspired neutrals and brand blue (white surfaces, `#2557a7` accent, `#2d2d2d` / `#767676` text, `#d4d2d0` borders).
15
     /// Indeed.com-inspired neutrals and brand blue (white surfaces, `#2557a7` accent, `#2d2d2d` / `#767676` text, `#d4d2d0` borders).
11
     private enum Theme {
16
     private enum Theme {
@@ -55,8 +60,15 @@ final class DashboardView: NSView, NSTextFieldDelegate {
55
     private let jobListingsStack = NSStackView()
60
     private let jobListingsStack = NSStackView()
56
     /// Shown when a sidebar item other than Home is selected.
61
     /// Shown when a sidebar item other than Home is selected.
57
     private let nonHomeHost = NSView()
62
     private let nonHomeHost = NSView()
63
+    private let nonHomeGenericContainer = NSView()
58
     private let nonHomeTitleLabel = NSTextField(labelWithString: "")
64
     private let nonHomeTitleLabel = NSTextField(labelWithString: "")
59
     private let nonHomeSubtitleLabel = NSTextField(wrappingLabelWithString: "")
65
     private let nonHomeSubtitleLabel = NSTextField(wrappingLabelWithString: "")
66
+    private let savedJobsPageContainer = NSView()
67
+    private let savedJobsPageTitleLabel = NSTextField(labelWithString: "Saved Jobs")
68
+    private let savedJobsPageSubtitleLabel = NSTextField(wrappingLabelWithString: "")
69
+    private let savedJobsScrollView = NSScrollView()
70
+    private let savedJobsDocumentView = JobListingsDocumentView()
71
+    private let savedJobsStack = NSStackView()
60
 
72
 
61
     private var currentSidebarItems: [SidebarItem] = []
73
     private var currentSidebarItems: [SidebarItem] = []
62
     private var selectedSidebarIndex: Int = 0
74
     private var selectedSidebarIndex: Int = 0
@@ -65,7 +77,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
65
     /// Last successful search result set (used when removing a card with the dismiss control).
77
     /// Last successful search result set (used when removing a card with the dismiss control).
66
     private var lastSearchResults: [JobListing] = []
78
     private var lastSearchResults: [JobListing] = []
67
     private var lastNoResultsQuery: String?
79
     private var lastNoResultsQuery: String?
68
-    private var savedJobs: Set<JobListing> = []
80
+    /// Most recently saved jobs appear first; persisted across launches.
81
+    private var savedJobOrder: [JobListing] = []
69
 
82
 
70
     override init(frame frameRect: NSRect) {
83
     override init(frame frameRect: NSRect) {
71
         super.init(frame: frameRect)
84
         super.init(frame: frameRect)
@@ -94,7 +107,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
94
         }
107
         }
95
         configureSidebar()
108
         configureSidebar()
96
         catalogJobListings = data.jobListings
109
         catalogJobListings = data.jobListings
97
-        savedJobs = []
110
+        savedJobOrder = Self.normalizedSavedJobs(SavedJobsStore.load())
98
         configureJobListings([], noResultsForQuery: nil)
111
         configureJobListings([], noResultsForQuery: nil)
99
         updateMainContentVisibility()
112
         updateMainContentVisibility()
100
     }
113
     }
@@ -251,12 +264,16 @@ final class DashboardView: NSView, NSTextFieldDelegate {
251
     }
264
     }
252
 
265
 
253
     private func updateJobListingDescriptionWidths() {
266
     private func updateJobListingDescriptionWidths() {
254
-        let containerWidth = jobListingsContainer.bounds.width
267
+        updateDescriptionColumnWidths(in: jobListingsStack, containerWidth: jobListingsContainer.bounds.width)
268
+        updateDescriptionColumnWidths(in: savedJobsStack, containerWidth: savedJobsDocumentView.bounds.width)
269
+    }
270
+
271
+    private func updateDescriptionColumnWidths(in stack: NSStackView, containerWidth: CGFloat) {
255
         guard containerWidth > 1 else { return }
272
         guard containerWidth > 1 else { return }
256
         let buttonStripReserve: CGFloat = 200
273
         let buttonStripReserve: CGFloat = 200
257
         let fallbackTextColumn = max(1, containerWidth - 32 - buttonStripReserve)
274
         let fallbackTextColumn = max(1, containerWidth - 32 - buttonStripReserve)
258
         var didChange = false
275
         var didChange = false
259
-        for card in jobListingsStack.arrangedSubviews {
276
+        for card in stack.arrangedSubviews {
260
             guard let desc = card.viewWithTag(502) as? NSTextField else { continue }
277
             guard let desc = card.viewWithTag(502) as? NSTextField else { continue }
261
             let columnWidth: CGFloat
278
             let columnWidth: CGFloat
262
             if let column = desc.superview, column.bounds.width > 1 {
279
             if let column = desc.superview, column.bounds.width > 1 {
@@ -271,8 +288,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
271
             }
288
             }
272
         }
289
         }
273
         if didChange {
290
         if didChange {
274
-            // Wrapping width changed, so card heights need to recompute against the new intrinsic sizes.
275
-            jobListingsStack.needsLayout = true
291
+            stack.needsLayout = true
276
         }
292
         }
277
     }
293
     }
278
 
294
 
@@ -297,14 +313,41 @@ final class DashboardView: NSView, NSTextFieldDelegate {
297
             return
313
             return
298
         }
314
         }
299
         for job in jobs {
315
         for job in jobs {
300
-            let card = makeJobListingCard(job)
316
+            let card = makeJobListingCard(job, context: .homeSearchResults)
301
             jobListingsStack.addArrangedSubview(card)
317
             jobListingsStack.addArrangedSubview(card)
302
             // Force every card to span the full row instead of hugging its intrinsic content width.
318
             // Force every card to span the full row instead of hugging its intrinsic content width.
303
             card.widthAnchor.constraint(equalTo: jobListingsStack.widthAnchor).isActive = true
319
             card.widthAnchor.constraint(equalTo: jobListingsStack.widthAnchor).isActive = true
304
         }
320
         }
305
     }
321
     }
306
 
322
 
307
-    private func makeJobListingCard(_ job: JobListing) -> NSView {
323
+    private static func normalizedSavedJobs(_ jobs: [JobListing]) -> [JobListing] {
324
+        var seen = Set<JobListing>()
325
+        var out: [JobListing] = []
326
+        for job in jobs where seen.insert(job).inserted {
327
+            out.append(job)
328
+        }
329
+        return out
330
+    }
331
+
332
+    private func isJobSaved(_ job: JobListing) -> Bool {
333
+        savedJobOrder.contains(job)
334
+    }
335
+
336
+    private func persistSavedJobs() {
337
+        SavedJobsStore.save(savedJobOrder)
338
+    }
339
+
340
+    private func applySavedState(_ saved: Bool, for job: JobListing) {
341
+        if saved {
342
+            savedJobOrder.removeAll { $0 == job }
343
+            savedJobOrder.insert(job, at: 0)
344
+        } else {
345
+            savedJobOrder.removeAll { $0 == job }
346
+        }
347
+        persistSavedJobs()
348
+    }
349
+
350
+    private func makeJobListingCard(_ job: JobListing, context: JobListingCardContext) -> NSView {
308
         let card = NSView()
351
         let card = NSView()
309
         card.translatesAutoresizingMaskIntoConstraints = false
352
         card.translatesAutoresizingMaskIntoConstraints = false
310
         card.wantsLayer = true
353
         card.wantsLayer = true
@@ -331,6 +374,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
331
 
374
 
332
         let applyButton = JobPayloadButton(title: "Apply", target: self, action: #selector(didTapJobApply(_:)))
375
         let applyButton = JobPayloadButton(title: "Apply", target: self, action: #selector(didTapJobApply(_:)))
333
         applyButton.jobPayload = job
376
         applyButton.jobPayload = job
377
+        applyButton.cardContext = context
334
         applyButton.isBordered = false
378
         applyButton.isBordered = false
335
         applyButton.bezelStyle = .rounded
379
         applyButton.bezelStyle = .rounded
336
         applyButton.font = .systemFont(ofSize: 13, weight: .semibold)
380
         applyButton.font = .systemFont(ofSize: 13, weight: .semibold)
@@ -342,20 +386,23 @@ final class DashboardView: NSView, NSTextFieldDelegate {
342
         applyButton.setContentHuggingPriority(.required, for: .horizontal)
386
         applyButton.setContentHuggingPriority(.required, for: .horizontal)
343
         applyButton.setContentCompressionResistancePriority(.required, for: .horizontal)
387
         applyButton.setContentCompressionResistancePriority(.required, for: .horizontal)
344
 
388
 
345
-        let savedButton = JobPayloadButton(title: "Saved", target: self, action: #selector(didTapJobSaved(_:)))
389
+        let savedOn = isJobSaved(job)
390
+        let savedButton = JobPayloadButton(title: savedOn ? "Saved" : "Save", target: self, action: #selector(didTapJobSaved(_:)))
346
         savedButton.jobPayload = job
391
         savedButton.jobPayload = job
392
+        savedButton.cardContext = context
347
         savedButton.setButtonType(.toggle)
393
         savedButton.setButtonType(.toggle)
348
         savedButton.isBordered = false
394
         savedButton.isBordered = false
349
         savedButton.bezelStyle = .rounded
395
         savedButton.bezelStyle = .rounded
350
         savedButton.font = .systemFont(ofSize: 13, weight: .semibold)
396
         savedButton.font = .systemFont(ofSize: 13, weight: .semibold)
351
         savedButton.focusRingType = .none
397
         savedButton.focusRingType = .none
352
-        savedButton.state = savedJobs.contains(job) ? .on : .off
398
+        savedButton.state = savedOn ? .on : .off
353
         styleJobSavedButton(savedButton)
399
         styleJobSavedButton(savedButton)
354
         savedButton.setContentHuggingPriority(.required, for: .horizontal)
400
         savedButton.setContentHuggingPriority(.required, for: .horizontal)
355
         savedButton.setContentCompressionResistancePriority(.required, for: .horizontal)
401
         savedButton.setContentCompressionResistancePriority(.required, for: .horizontal)
356
 
402
 
357
         let dismissButton = JobPayloadButton()
403
         let dismissButton = JobPayloadButton()
358
         dismissButton.jobPayload = job
404
         dismissButton.jobPayload = job
405
+        dismissButton.cardContext = context
359
         dismissButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Dismiss")
406
         dismissButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Dismiss")
360
         dismissButton.imagePosition = .imageOnly
407
         dismissButton.imagePosition = .imageOnly
361
         dismissButton.imageScaling = .scaleProportionallyDown
408
         dismissButton.imageScaling = .scaleProportionallyDown
@@ -365,7 +412,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
365
         dismissButton.contentTintColor = Theme.secondaryText
412
         dismissButton.contentTintColor = Theme.secondaryText
366
         dismissButton.target = self
413
         dismissButton.target = self
367
         dismissButton.action = #selector(didTapJobDismiss(_:))
414
         dismissButton.action = #selector(didTapJobDismiss(_:))
368
-        dismissButton.toolTip = "Dismiss"
415
+        dismissButton.toolTip = context == .savedJobsPage ? "Remove from saved" : "Dismiss"
369
         dismissButton.focusRingType = .none
416
         dismissButton.focusRingType = .none
370
         dismissButton.setContentHuggingPriority(.required, for: .horizontal)
417
         dismissButton.setContentHuggingPriority(.required, for: .horizontal)
371
 
418
 
@@ -446,19 +493,29 @@ final class DashboardView: NSView, NSTextFieldDelegate {
446
 
493
 
447
     @objc private func didTapJobSaved(_ sender: NSButton) {
494
     @objc private func didTapJobSaved(_ sender: NSButton) {
448
         guard let job = (sender as? JobPayloadButton)?.jobPayload else { return }
495
         guard let job = (sender as? JobPayloadButton)?.jobPayload else { return }
449
-        if savedJobs.contains(job) {
450
-            savedJobs.remove(job)
451
-        } else {
452
-            savedJobs.insert(job)
453
-        }
454
-        sender.state = savedJobs.contains(job) ? .on : .off
496
+        let willSave = !isJobSaved(job)
497
+        applySavedState(willSave, for: job)
498
+        sender.state = willSave ? .on : .off
499
+        sender.title = willSave ? "Saved" : "Save"
455
         styleJobSavedButton(sender)
500
         styleJobSavedButton(sender)
501
+        if isSavedJobsSidebarIndex(selectedSidebarIndex) {
502
+            reloadSavedJobsListings()
503
+        }
456
     }
504
     }
457
 
505
 
458
     @objc private func didTapJobDismiss(_ sender: NSButton) {
506
     @objc private func didTapJobDismiss(_ sender: NSButton) {
459
-        guard let job = (sender as? JobPayloadButton)?.jobPayload else { return }
460
-        lastSearchResults.removeAll { $0 == job }
461
-        configureJobListings(lastSearchResults, noResultsForQuery: lastNoResultsQuery)
507
+        guard let button = sender as? JobPayloadButton, let job = button.jobPayload else { return }
508
+        switch button.cardContext {
509
+        case .homeSearchResults:
510
+            lastSearchResults.removeAll { $0 == job }
511
+            configureJobListings(lastSearchResults, noResultsForQuery: lastNoResultsQuery)
512
+        case .savedJobsPage:
513
+            applySavedState(false, for: job)
514
+            reloadSavedJobsListings()
515
+            if isHomeSidebarIndex(selectedSidebarIndex), !lastSearchResults.isEmpty {
516
+                configureJobListings(lastSearchResults, noResultsForQuery: lastNoResultsQuery, updateLastResults: false)
517
+            }
518
+        }
462
     }
519
     }
463
 
520
 
464
     private func configureSearchBar() {
521
     private func configureSearchBar() {
@@ -630,6 +687,23 @@ final class DashboardView: NSView, NSTextFieldDelegate {
630
         nonHomeHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor
687
         nonHomeHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor
631
         nonHomeHost.isHidden = true
688
         nonHomeHost.isHidden = true
632
 
689
 
690
+        nonHomeGenericContainer.translatesAutoresizingMaskIntoConstraints = false
691
+        savedJobsPageContainer.translatesAutoresizingMaskIntoConstraints = false
692
+        nonHomeHost.addSubview(nonHomeGenericContainer)
693
+        nonHomeHost.addSubview(savedJobsPageContainer)
694
+
695
+        NSLayoutConstraint.activate([
696
+            nonHomeGenericContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor),
697
+            nonHomeGenericContainer.trailingAnchor.constraint(equalTo: nonHomeHost.trailingAnchor),
698
+            nonHomeGenericContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor),
699
+            nonHomeGenericContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor),
700
+
701
+            savedJobsPageContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor),
702
+            savedJobsPageContainer.trailingAnchor.constraint(equalTo: nonHomeHost.trailingAnchor),
703
+            savedJobsPageContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor),
704
+            savedJobsPageContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor)
705
+        ])
706
+
633
         nonHomeTitleLabel.font = .systemFont(ofSize: 22, weight: .bold)
707
         nonHomeTitleLabel.font = .systemFont(ofSize: 22, weight: .bold)
634
         nonHomeTitleLabel.textColor = Theme.primaryText
708
         nonHomeTitleLabel.textColor = Theme.primaryText
635
         nonHomeTitleLabel.alignment = .center
709
         nonHomeTitleLabel.alignment = .center
@@ -641,19 +715,108 @@ final class DashboardView: NSView, NSTextFieldDelegate {
641
         nonHomeSubtitleLabel.maximumNumberOfLines = 0
715
         nonHomeSubtitleLabel.maximumNumberOfLines = 0
642
         nonHomeSubtitleLabel.stringValue = "This area is not available in the preview build. Use Home to search jobs."
716
         nonHomeSubtitleLabel.stringValue = "This area is not available in the preview build. Use Home to search jobs."
643
 
717
 
644
-        let stack = NSStackView(views: [nonHomeTitleLabel, nonHomeSubtitleLabel])
645
-        stack.orientation = .vertical
646
-        stack.spacing = 10
647
-        stack.alignment = .centerX
648
-        stack.translatesAutoresizingMaskIntoConstraints = false
649
-        nonHomeHost.addSubview(stack)
718
+        let genericStack = NSStackView(views: [nonHomeTitleLabel, nonHomeSubtitleLabel])
719
+        genericStack.orientation = .vertical
720
+        genericStack.spacing = 10
721
+        genericStack.alignment = .centerX
722
+        genericStack.translatesAutoresizingMaskIntoConstraints = false
723
+        nonHomeGenericContainer.addSubview(genericStack)
650
         NSLayoutConstraint.activate([
724
         NSLayoutConstraint.activate([
651
-            stack.centerXAnchor.constraint(equalTo: nonHomeHost.centerXAnchor),
652
-            stack.centerYAnchor.constraint(equalTo: nonHomeHost.centerYAnchor),
653
-            stack.leadingAnchor.constraint(greaterThanOrEqualTo: nonHomeHost.leadingAnchor, constant: 32),
654
-            stack.trailingAnchor.constraint(lessThanOrEqualTo: nonHomeHost.trailingAnchor, constant: -32),
725
+            genericStack.centerXAnchor.constraint(equalTo: nonHomeGenericContainer.centerXAnchor),
726
+            genericStack.centerYAnchor.constraint(equalTo: nonHomeGenericContainer.centerYAnchor),
727
+            genericStack.leadingAnchor.constraint(greaterThanOrEqualTo: nonHomeGenericContainer.leadingAnchor, constant: 32),
728
+            genericStack.trailingAnchor.constraint(lessThanOrEqualTo: nonHomeGenericContainer.trailingAnchor, constant: -32),
655
             nonHomeSubtitleLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 420)
729
             nonHomeSubtitleLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 420)
656
         ])
730
         ])
731
+
732
+        savedJobsPageTitleLabel.font = .systemFont(ofSize: 22, weight: .bold)
733
+        savedJobsPageTitleLabel.textColor = Theme.primaryText
734
+        savedJobsPageTitleLabel.alignment = .left
735
+        savedJobsPageTitleLabel.maximumNumberOfLines = 1
736
+
737
+        savedJobsPageSubtitleLabel.font = .systemFont(ofSize: 14, weight: .regular)
738
+        savedJobsPageSubtitleLabel.textColor = Theme.secondaryText
739
+        savedJobsPageSubtitleLabel.alignment = .left
740
+        savedJobsPageSubtitleLabel.maximumNumberOfLines = 0
741
+
742
+        savedJobsDocumentView.translatesAutoresizingMaskIntoConstraints = false
743
+        savedJobsStack.orientation = .vertical
744
+        savedJobsStack.spacing = 14
745
+        savedJobsStack.alignment = .leading
746
+        savedJobsStack.distribution = .fill
747
+        savedJobsStack.translatesAutoresizingMaskIntoConstraints = false
748
+        savedJobsStack.setContentHuggingPriority(.defaultHigh, for: .vertical)
749
+        savedJobsStack.setHuggingPriority(.defaultLow, for: .horizontal)
750
+        savedJobsDocumentView.addSubview(savedJobsStack)
751
+        NSLayoutConstraint.activate([
752
+            savedJobsStack.leadingAnchor.constraint(equalTo: savedJobsDocumentView.leadingAnchor),
753
+            savedJobsStack.trailingAnchor.constraint(equalTo: savedJobsDocumentView.trailingAnchor),
754
+            savedJobsStack.topAnchor.constraint(equalTo: savedJobsDocumentView.topAnchor),
755
+            savedJobsStack.bottomAnchor.constraint(equalTo: savedJobsDocumentView.bottomAnchor)
756
+        ])
757
+
758
+        savedJobsScrollView.translatesAutoresizingMaskIntoConstraints = false
759
+        savedJobsScrollView.hasVerticalScroller = true
760
+        savedJobsScrollView.hasHorizontalScroller = false
761
+        savedJobsScrollView.autohidesScrollers = true
762
+        savedJobsScrollView.drawsBackground = false
763
+        savedJobsScrollView.borderType = .noBorder
764
+        savedJobsScrollView.documentView = savedJobsDocumentView
765
+        savedJobsScrollView.setContentHuggingPriority(.defaultLow, for: .vertical)
766
+        savedJobsScrollView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
767
+
768
+        let savedHeaderStack = NSStackView(views: [savedJobsPageTitleLabel, savedJobsPageSubtitleLabel])
769
+        savedHeaderStack.orientation = .vertical
770
+        savedHeaderStack.spacing = 6
771
+        savedHeaderStack.alignment = .leading
772
+        savedHeaderStack.translatesAutoresizingMaskIntoConstraints = false
773
+
774
+        let savedOuterStack = NSStackView(views: [savedHeaderStack, savedJobsScrollView])
775
+        savedOuterStack.orientation = .vertical
776
+        savedOuterStack.spacing = 16
777
+        savedOuterStack.alignment = .width
778
+        savedOuterStack.translatesAutoresizingMaskIntoConstraints = false
779
+        savedJobsPageContainer.addSubview(savedOuterStack)
780
+        NSLayoutConstraint.activate([
781
+            savedOuterStack.leadingAnchor.constraint(equalTo: savedJobsPageContainer.leadingAnchor, constant: 32),
782
+            savedOuterStack.trailingAnchor.constraint(equalTo: savedJobsPageContainer.trailingAnchor, constant: -32),
783
+            savedOuterStack.topAnchor.constraint(equalTo: savedJobsPageContainer.topAnchor, constant: 8),
784
+            savedOuterStack.bottomAnchor.constraint(equalTo: savedJobsPageContainer.bottomAnchor),
785
+
786
+            savedJobsDocumentView.topAnchor.constraint(equalTo: savedJobsScrollView.contentView.topAnchor),
787
+            savedJobsDocumentView.leadingAnchor.constraint(equalTo: savedJobsScrollView.contentView.leadingAnchor),
788
+            savedJobsDocumentView.widthAnchor.constraint(equalTo: savedJobsScrollView.contentView.widthAnchor)
789
+        ])
790
+    }
791
+
792
+    private func reloadSavedJobsListings() {
793
+        savedJobsStack.arrangedSubviews.forEach {
794
+            savedJobsStack.removeArrangedSubview($0)
795
+            $0.removeFromSuperview()
796
+        }
797
+        if savedJobOrder.isEmpty {
798
+            savedJobsPageSubtitleLabel.stringValue = "Save jobs from Home to see them here."
799
+            let empty = NSTextField(wrappingLabelWithString: "No saved jobs yet. Search on Home, then tap Save on a listing.")
800
+            empty.font = .systemFont(ofSize: 14, weight: .regular)
801
+            empty.textColor = Theme.secondaryText
802
+            empty.alignment = .left
803
+            empty.maximumNumberOfLines = 0
804
+            empty.translatesAutoresizingMaskIntoConstraints = false
805
+            savedJobsStack.addArrangedSubview(empty)
806
+            empty.widthAnchor.constraint(equalTo: savedJobsStack.widthAnchor).isActive = true
807
+            return
808
+        }
809
+        savedJobsPageSubtitleLabel.stringValue = "\(savedJobOrder.count) saved \(savedJobOrder.count == 1 ? "position" : "positions")"
810
+        for job in savedJobOrder {
811
+            let card = makeJobListingCard(job, context: .savedJobsPage)
812
+            savedJobsStack.addArrangedSubview(card)
813
+            card.widthAnchor.constraint(equalTo: savedJobsStack.widthAnchor).isActive = true
814
+        }
815
+    }
816
+
817
+    private func isSavedJobsSidebarIndex(_ index: Int) -> Bool {
818
+        guard index >= 0, index < currentSidebarItems.count else { return false }
819
+        return currentSidebarItems[index].title == "Saved Jobs"
657
     }
820
     }
658
 
821
 
659
     private func isHomeSidebarIndex(_ index: Int) -> Bool {
822
     private func isHomeSidebarIndex(_ index: Int) -> Bool {
@@ -663,10 +826,17 @@ final class DashboardView: NSView, NSTextFieldDelegate {
663
 
826
 
664
     private func updateMainContentVisibility() {
827
     private func updateMainContentVisibility() {
665
         let home = isHomeSidebarIndex(selectedSidebarIndex)
828
         let home = isHomeSidebarIndex(selectedSidebarIndex)
829
+        let savedJobs = isSavedJobsSidebarIndex(selectedSidebarIndex)
666
         mainOverlay.isHidden = !home
830
         mainOverlay.isHidden = !home
667
         nonHomeHost.isHidden = home
831
         nonHomeHost.isHidden = home
832
+        nonHomeGenericContainer.isHidden = savedJobs
833
+        savedJobsPageContainer.isHidden = !savedJobs
668
         if !home, selectedSidebarIndex < currentSidebarItems.count {
834
         if !home, selectedSidebarIndex < currentSidebarItems.count {
669
-            nonHomeTitleLabel.stringValue = currentSidebarItems[selectedSidebarIndex].title
835
+            if savedJobs {
836
+                reloadSavedJobsListings()
837
+            } else {
838
+                nonHomeTitleLabel.stringValue = currentSidebarItems[selectedSidebarIndex].title
839
+            }
670
         }
840
         }
671
     }
841
     }
672
 
842
 
@@ -950,6 +1120,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
950
 /// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target).
1120
 /// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target).
951
 private final class JobPayloadButton: NSButton {
1121
 private final class JobPayloadButton: NSButton {
952
     var jobPayload: JobListing?
1122
     var jobPayload: JobListing?
1123
+    var cardContext: JobListingCardContext = .homeSearchResults
953
 }
1124
 }
954
 
1125
 
955
 /// Document view for the job list `NSScrollView`; flipped coordinates keep short result sets aligned to the top of the clip (avoids a large empty band above the cards on macOS).
1126
 /// Document view for the job list `NSScrollView`; flipped coordinates keep short result sets aligned to the top of the clip (avoids a large empty band above the cards on macOS).