Procházet zdrojové kódy

Add Phase 5 SwiftData saved jobs with 24h revalidation.

Persist save/unsave across relaunch with indeedJobKey dedupe, keep unavailable listings visible, and enable Settings clear/export.

Co-authored-by: Cursor <cursoragent@cursor.com>
faiz ali před 14 hodinami
rodič
revize
cfebdd4346

+ 65 - 0
Docs/Phase5/Saved_Jobs_Persistence.md

@@ -0,0 +1,65 @@
+# Phase 5 — Saved jobs persistence and revalidation
+
+**Status:** Implemented  
+**Date:** 2026-10-12
+
+## Scope
+
+- Persist saved Indeed listings with **SwiftData**
+- Dedupe on save by `indeedJobKey`
+- Keep unavailable jobs visible with status; Open on Indeed still offered
+- Background revalidation (24h stale interval)
+- Settings: clear local saved jobs + JSON export
+
+## Architecture
+
+```
+Save toggle (Search / Saved)
+  → SavedJobsStore (SwiftDataSavedJobsStore | MockSavedJobsStore)
+  → SavedJobEntity (unique indeedJobKey + JobListing JSON snapshot)
+  → UI refresh via ViewModel version bumps
+
+Launch / Saved Jobs appear
+  → SavedJobsRevalidator.runPass()
+  → JobValidationService (source signal → optional HEAD)
+  → updateListing(availability, lastVerifiedAt)
+```
+
+## What shipped
+
+| Component | Path |
+|-----------|------|
+| Model container | `Persistence/PersistenceController.swift` |
+| SwiftData entity | `Persistence/SavedJobEntity.swift` |
+| Persisted store | `Persistence/SwiftDataSavedJobsStore.swift` |
+| Revalidator | `Persistence/SavedJobsRevalidator.swift` |
+| Protocol | `SavedJobsStore` (+ `updateListing`, `clearAll`) |
+| Live DI | `AppDependencies.live` → SwiftData + HTTP HEAD for saved revalidation |
+| Preview DI | `AppDependencies.preview` → `MockSavedJobsStore` |
+| Settings | Clear local data + export JSON |
+| Tests | `indeed_ai_job_searchTests/SavedJobsPersistenceTests.swift` |
+
+## Policies
+
+| Policy | Value |
+|--------|-------|
+| Dedupe key | `indeedJobKey` (unique SwiftData attribute) |
+| Snapshot | Full `JobListing` JSON in entity |
+| Revalidation interval | **24 hours** (`SavedJobsRevalidator.defaultStaleInterval`) |
+| Unavailable rows | Kept; muted/strikethrough via existing `JobRowView` |
+| Search validation | Unchanged — source signals only (no HEAD during search) |
+| Saved revalidation | Source signal + `HTTPJobValidationService` HEAD when unknown |
+
+## Acceptance checklist
+
+- [x] Save/unsave survives relaunch (SwiftData disk store)
+- [x] Dedupe on save by `indeedJobKey`
+- [x] Unavailable jobs remain visible with status; Open on Indeed still available
+- [x] Stale saved jobs revalidated on launch / Saved Jobs navigation
+- [x] Settings clear local data + export JSON
+- [x] Unit tests for store + revalidator
+
+## Out of scope (Phase 6+)
+
+- Persisted job alerts / notification history
+- Cloud sync / iCloud

+ 41 - 11
indeed_ai_job_search/App/AppDependencies.swift

@@ -11,26 +11,32 @@ final class AppDependencies {
     let searchInterpreter: OpenAISearchService
     let validationService: JobValidationService
     let searchPipeline: JobSearchPipeline
-    let savedJobsStore: MockSavedJobsStore
+    let savedJobsStore: any SavedJobsStore
+    let savedJobsRevalidator: SavedJobsRevalidator
     let alertsStore: MockAlertsStore
+    let persistenceController: PersistenceController?
 
     init(
         jobDataSource: JobDataSource,
         searchInterpreter: OpenAISearchService,
         validationService: JobValidationService,
         searchPipeline: JobSearchPipeline,
-        savedJobsStore: MockSavedJobsStore,
-        alertsStore: MockAlertsStore
+        savedJobsStore: any SavedJobsStore,
+        savedJobsRevalidator: SavedJobsRevalidator,
+        alertsStore: MockAlertsStore,
+        persistenceController: PersistenceController? = nil
     ) {
         self.jobDataSource = jobDataSource
         self.searchInterpreter = searchInterpreter
         self.validationService = validationService
         self.searchPipeline = searchPipeline
         self.savedJobsStore = savedJobsStore
+        self.savedJobsRevalidator = savedJobsRevalidator
         self.alertsStore = alertsStore
+        self.persistenceController = persistenceController
     }
 
-    /// Preview / SwiftUI canvas — mocks only, no network.
+    /// Preview / SwiftUI canvas — mocks only, no network, in-memory saves.
     static var preview: AppDependencies {
         let interpreter = MockSearchInterpreter()
         let dataSource = MockJobDataSource()
@@ -45,18 +51,26 @@ final class AppDependencies {
             validationService: validation,
             cache: NullJobListingCache()
         )
+        let savedStore = MockSavedJobsStore()
+        let revalidator = SavedJobsRevalidator(
+            store: savedStore,
+            validationService: validation
+        )
         return AppDependencies(
             jobDataSource: dataSource,
             searchInterpreter: interpreter,
             validationService: validation,
             searchPipeline: pipeline,
-            savedJobsStore: MockSavedJobsStore(),
-            alertsStore: MockAlertsStore()
+            savedJobsStore: savedStore,
+            savedJobsRevalidator: revalidator,
+            alertsStore: MockAlertsStore(),
+            persistenceController: nil
         )
     }
 
     /// App launch — OpenAI Responses + web_search (Indeed-only) for live job rows.
     /// Interpretation/ranking still use Chat Completions (gpt-6-luna) when available.
+    /// Saved jobs persist via SwiftData; revalidation uses source signals + optional HEAD.
     static var live: AppDependencies {
         let mockInterpreter = MockSearchInterpreter()
         let heuristicRanking = HeuristicJobRankingService()
@@ -88,7 +102,8 @@ final class AppDependencies {
             enrichmentSource = nil
         }
 
-        let validation = CompositeJobValidationService(
+        // Search pipeline: source signals only (avoid unsolicited HEAD during search).
+        let searchValidation = CompositeJobValidationService(
             source: SourceSignalJobValidationService(),
             http: nil
         )
@@ -97,17 +112,32 @@ final class AppDependencies {
             dataSource: primarySource,
             enrichmentDataSource: enrichmentSource,
             rankingService: ranking,
-            validationService: validation,
+            validationService: searchValidation,
             cache: InMemoryIndeedListingCache()
         )
 
+        // Saved jobs: source signal first, then lightweight HEAD when still unknown.
+        let savedValidation = CompositeJobValidationService(
+            source: SourceSignalJobValidationService(),
+            http: HTTPJobValidationService()
+        )
+
+        let persistence = PersistenceController.shared
+        let savedStore = SwiftDataSavedJobsStore(controller: persistence)
+        let revalidator = SavedJobsRevalidator(
+            store: savedStore,
+            validationService: savedValidation
+        )
+
         return AppDependencies(
             jobDataSource: primarySource,
             searchInterpreter: interpreter,
-            validationService: validation,
+            validationService: searchValidation,
             searchPipeline: pipeline,
-            savedJobsStore: MockSavedJobsStore(),
-            alertsStore: MockAlertsStore()
+            savedJobsStore: savedStore,
+            savedJobsRevalidator: revalidator,
+            alertsStore: MockAlertsStore(),
+            persistenceController: persistence
         )
     }
 }

+ 16 - 4
indeed_ai_job_search/App/RootShellView.swift

@@ -17,10 +17,16 @@ struct RootShellView: View {
 
     init(dependencies: AppDependencies) {
         self.dependencies = dependencies
-        _jobSearchVM = State(initialValue: JobSearchViewModel(dependencies: dependencies))
-        _savedJobsVM = State(initialValue: SavedJobsViewModel(dependencies: dependencies))
-        _alertsVM = State(initialValue: JobAlertsViewModel(dependencies: dependencies))
-        _settingsVM = State(initialValue: SettingsViewModel())
+        let jobSearch = JobSearchViewModel(dependencies: dependencies)
+        let savedJobs = SavedJobsViewModel(dependencies: dependencies)
+        let alerts = JobAlertsViewModel(dependencies: dependencies)
+        _jobSearchVM = State(initialValue: jobSearch)
+        _savedJobsVM = State(initialValue: savedJobs)
+        _alertsVM = State(initialValue: alerts)
+        _settingsVM = State(initialValue: SettingsViewModel(dependencies: dependencies) {
+            savedJobs.refreshFromStore()
+            jobSearch.refreshSaveState()
+        })
     }
 
     var body: some View {
@@ -72,8 +78,14 @@ struct RootShellView: View {
         .onChange(of: selection) { _, newValue in
             if newValue == .savedJobs {
                 savedJobsVM.refreshFromStore()
+                savedJobsVM.revalidateStaleJobs()
             }
         }
+        .task {
+            // Soft revalidation shortly after launch so stale saves update without blocking UI.
+            try? await Task.sleep(nanoseconds: 1_500_000_000)
+            savedJobsVM.revalidateStaleJobs()
+        }
         .background(keyboardShortcuts)
     }
 

+ 3 - 0
indeed_ai_job_search/Domain/Protocols/JobSearchProtocols.swift

@@ -125,6 +125,9 @@ protocol SavedJobsStore: AnyObject {
     func isSaved(_ listing: JobListing) -> Bool
     func toggleSave(_ listing: JobListing)
     func remove(_ listing: JobListing)
+    /// Updates the persisted listing snapshot and verification timestamp (revalidation).
+    func updateListing(_ listing: JobListing, lastVerifiedAt: Date)
+    func clearAll()
 }
 
 protocol AlertsStore: AnyObject {

+ 6 - 1
indeed_ai_job_search/Features/JobSearch/ViewModels/JobSearchViewModel.swift

@@ -34,7 +34,7 @@ final class JobSearchViewModel {
     ]
 
     private let pipeline: JobSearchPipeline
-    private let savedStore: MockSavedJobsStore
+    private let savedStore: any SavedJobsStore
     private var searchTask: Task<Void, Never>?
     private var filterDebounceTask: Task<Void, Never>?
     /// Ignores filter-panel onChange while pipeline-applied criteria propagates to the UI.
@@ -172,6 +172,11 @@ final class JobSearchViewModel {
         saveVersion += 1
     }
 
+    /// Call after external store mutations (e.g. Settings → Clear local data).
+    func refreshSaveState() {
+        saveVersion += 1
+    }
+
     func retry() {
         submitSearch()
     }

+ 18 - 1
indeed_ai_job_search/Features/SavedJobs/ViewModels/SavedJobsViewModel.swift

@@ -55,11 +55,14 @@ final class SavedJobsViewModel {
     var availabilityFilter: SavedAvailabilityFilter = .all
     var workModeFilter: SavedWorkModeFilter = .all
     private(set) var listVersion = 0
+    private(set) var isRevalidating = false
 
-    let store: MockSavedJobsStore
+    let store: any SavedJobsStore
+    private let revalidator: SavedJobsRevalidator
 
     init(dependencies: AppDependencies) {
         self.store = dependencies.savedJobsStore
+        self.revalidator = dependencies.savedJobsRevalidator
     }
 
     var savedJobs: [SavedJob] {
@@ -124,6 +127,20 @@ final class SavedJobsViewModel {
     }
 
     func refreshFromStore() {
+        if let swiftDataStore = store as? SwiftDataSavedJobsStore {
+            swiftDataStore.reload()
+        }
         listVersion += 1
     }
+
+    /// Revalidates stale saved jobs (24h) and refreshes the list when finished.
+    func revalidateStaleJobs() {
+        guard !isRevalidating else { return }
+        isRevalidating = true
+        Task { @MainActor in
+            _ = await revalidator.runPass()
+            refreshFromStore()
+            isRevalidating = false
+        }
+    }
 }

+ 10 - 0
indeed_ai_job_search/Features/SavedJobs/Views/SavedJobsView.swift

@@ -276,6 +276,16 @@ struct SavedJobsView: View {
                     .font(AppTypography.caption)
                     .foregroundStyle(AppColors.brand)
 
+                if saved.availability == .unavailable {
+                    Label("Unavailable", systemImage: "exclamationmark.triangle.fill")
+                        .font(AppTypography.caption)
+                        .foregroundStyle(AppColors.unavailable)
+                } else if let verified = saved.lastVerifiedAt {
+                    Text("Checked \(RelativeDateLabel.relativeString(from: verified))")
+                        .font(AppTypography.caption)
+                        .foregroundStyle(AppColors.textTertiary)
+                }
+
                 Spacer(minLength: 0)
 
                 OpenIndeedLinkButton(listing: saved.listing)

+ 86 - 1
indeed_ai_job_search/Features/Settings/ViewModels/SettingsViewModel.swift

@@ -7,6 +7,7 @@ import AppKit
 import Foundation
 import Observation
 import SwiftUI
+import UniformTypeIdentifiers
 
 @Observable
 @MainActor
@@ -17,6 +18,10 @@ final class SettingsViewModel {
 
     var notificationPermissionLabel = "Not requested"
     var restoreStatusMessage: String?
+    var dataActionMessage: String?
+    var showClearDataConfirmation = false
+    /// Bumps when local saved-job data changes so count labels refresh.
+    private(set) var dataVersion = 0
 
     /// Phase 1 placeholder — StoreKit wiring lands later.
     var isProActive = true
@@ -42,13 +47,34 @@ final class SettingsViewModel {
         }
     }
 
-    init() {
+    var savedJobCount: Int {
+        _ = dataVersion
+        return savedJobsStore.savedJobs.count
+    }
+
+    private let savedJobsStore: any SavedJobsStore
+    private let onLocalDataCleared: (() -> Void)?
+
+    init(
+        savedJobsStore: any SavedJobsStore,
+        onLocalDataCleared: (() -> Void)? = nil
+    ) {
+        self.savedJobsStore = savedJobsStore
+        self.onLocalDataCleared = onLocalDataCleared
+
         let defaults = UserDefaults.standard
         let raw = defaults.string(forKey: Keys.appearanceMode) ?? AppearanceMode.system.rawValue
         appearanceMode = AppearanceMode(rawValue: raw) ?? .system
         quietHoursEnabled = defaults.bool(forKey: Keys.quietHoursEnabled)
     }
 
+    convenience init(dependencies: AppDependencies, onLocalDataCleared: (() -> Void)? = nil) {
+        self.init(
+            savedJobsStore: dependencies.savedJobsStore,
+            onLocalDataCleared: onLocalDataCleared
+        )
+    }
+
     func restorePurchases() {
         restoreStatusMessage = isProActive
             ? "Your Pro purchase is already active on this Mac."
@@ -73,6 +99,51 @@ final class SettingsViewModel {
         openURL("https://hrtechprivacy.com/brands/indeed")
     }
 
+    func requestClearLocalData() {
+        showClearDataConfirmation = true
+    }
+
+    func confirmClearLocalData() {
+        showClearDataConfirmation = false
+        savedJobsStore.clearAll()
+        dataVersion += 1
+        onLocalDataCleared?()
+        dataActionMessage = "Local saved jobs were removed from this Mac."
+    }
+
+    func exportSavedJobs() {
+        let jobs = savedJobsStore.savedJobs
+        guard !jobs.isEmpty else {
+            dataActionMessage = "No saved jobs to export yet."
+            return
+        }
+
+        do {
+            let data: Data
+            if let swiftDataStore = savedJobsStore as? SwiftDataSavedJobsStore {
+                data = try swiftDataStore.exportJSON()
+            } else {
+                let encoder = JSONEncoder()
+                encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+                encoder.dateEncodingStrategy = .iso8601
+                data = try encoder.encode(jobs.map { ExportRow(from: $0) })
+            }
+
+            let panel = NSSavePanel()
+            panel.allowedContentTypes = [.json]
+            panel.nameFieldStringValue = "saved-jobs.json"
+            panel.canCreateDirectories = true
+            panel.title = "Export Saved Jobs"
+            panel.message = "Choose where to save a JSON backup of your saved Indeed listings."
+
+            guard panel.runModal() == .OK, let url = panel.url else { return }
+            try data.write(to: url, options: .atomic)
+            dataActionMessage = "Exported \(jobs.count) saved \(jobs.count == 1 ? "job" : "jobs")."
+        } catch {
+            dataActionMessage = "Couldn’t export saved jobs. Try again."
+        }
+    }
+
     private func openURL(_ string: String) {
         guard let url = URL(string: string) else { return }
         NSWorkspace.shared.open(url)
@@ -83,3 +154,17 @@ final class SettingsViewModel {
         static let quietHoursEnabled = "settings.quietHoursEnabled"
     }
 }
+
+private struct ExportRow: Codable {
+    var indeedJobKey: String
+    var savedAt: Date
+    var lastVerifiedAt: Date?
+    var listing: JobListing
+
+    init(from saved: SavedJob) {
+        indeedJobKey = saved.listing.indeedJobKey
+        savedAt = saved.savedAt
+        lastVerifiedAt = saved.lastVerifiedAt
+        listing = saved.listing
+    }
+}

+ 24 - 10
indeed_ai_job_search/Features/Settings/Views/SettingsView.swift

@@ -39,6 +39,20 @@ struct SettingsView: View {
         } message: {
             Text(viewModel.restoreStatusMessage ?? "")
         }
+        .alert("Clear local data?", isPresented: $viewModel.showClearDataConfirmation) {
+            Button("Clear", role: .destructive) { viewModel.confirmClearLocalData() }
+            Button("Cancel", role: .cancel) {}
+        } message: {
+            Text("This permanently removes \(viewModel.savedJobCount) saved \(viewModel.savedJobCount == 1 ? "job" : "jobs") from this Mac. Job alerts are unaffected until Phase 6 persistence.")
+        }
+        .alert("Data", isPresented: Binding(
+            get: { viewModel.dataActionMessage != nil },
+            set: { if !$0 { viewModel.dataActionMessage = nil } }
+        )) {
+            Button("OK", role: .cancel) { viewModel.dataActionMessage = nil }
+        } message: {
+            Text(viewModel.dataActionMessage ?? "")
+        }
     }
 
     // MARK: - Header
@@ -168,31 +182,31 @@ struct SettingsView: View {
     private var privacySection: some View {
         settingsCard(
             title: "Privacy & data",
-            subtitle: "Local saved jobs and alerts stay on this Mac."
+            subtitle: "Local saved jobs stay on this Mac via SwiftData."
         ) {
-            Text("Search queries may be processed by AI in later phases. Indeed listings open on indeed.com.")
+            Text("Search queries may be processed by AI. Indeed listings open on indeed.com. Saved jobs are stored locally and revalidated periodically.")
                 .font(AppTypography.body)
                 .foregroundStyle(AppColors.textSecondary)
 
             settingsRow(
                 icon: "trash",
                 title: "Clear local data",
-                subtitle: "Remove saved jobs and alerts stored on this Mac.",
-                showsChevron: false
+                subtitle: viewModel.savedJobCount == 0
+                    ? "No saved jobs on this Mac."
+                    : "Remove \(viewModel.savedJobCount) saved \(viewModel.savedJobCount == 1 ? "job" : "jobs") from this Mac.",
+                showsChevron: true,
+                action: { viewModel.requestClearLocalData() }
             )
-            .opacity(0.45)
-            .help("Available after persistence ships in Phase 5")
 
             Divider().foregroundStyle(AppColors.border)
 
             settingsRow(
                 icon: "square.and.arrow.up",
                 title: "Export saved jobs",
-                subtitle: "Download your saved listings for backup.",
-                showsChevron: false
+                subtitle: "Download a JSON backup of your saved Indeed listings.",
+                showsChevron: true,
+                action: { viewModel.exportSavedJobs() }
             )
-            .opacity(0.45)
-            .help("Available after persistence ships in Phase 5")
         }
     }
 

+ 40 - 0
indeed_ai_job_search/Persistence/PersistenceController.swift

@@ -0,0 +1,40 @@
+//
+//  PersistenceController.swift
+//  indeed_ai_job_search
+//
+//  Phase 5 — SwiftData container for saved jobs.
+//
+
+import Foundation
+import SwiftData
+
+@MainActor
+final class PersistenceController {
+    static let shared = PersistenceController(inMemory: false)
+
+    /// In-memory store for unit tests and isolated experiments.
+    static func makeInMemory() -> PersistenceController {
+        PersistenceController(inMemory: true)
+    }
+
+    let container: ModelContainer
+
+    var mainContext: ModelContext {
+        container.mainContext
+    }
+
+    init(inMemory: Bool) {
+        let schema = Schema([SavedJobEntity.self])
+        let configuration = ModelConfiguration(
+            "SavedJobs",
+            schema: schema,
+            isStoredInMemoryOnly: inMemory
+        )
+
+        do {
+            container = try ModelContainer(for: schema, configurations: [configuration])
+        } catch {
+            fatalError("Failed to create SwiftData ModelContainer: \(error)")
+        }
+    }
+}

+ 0 - 12
indeed_ai_job_search/Persistence/PersistencePlaceholder.swift

@@ -1,12 +0,0 @@
-//
-//  PersistencePlaceholder.swift
-//  indeed_ai_job_search
-//
-//  Phase 5 will replace this stub with SwiftData PersistenceController.
-//
-
-import Foundation
-
-enum PersistencePlaceholder {
-    static let phaseNote = "SwiftData saved jobs and alerts land in Phase 5."
-}

+ 67 - 0
indeed_ai_job_search/Persistence/SavedJobEntity.swift

@@ -0,0 +1,67 @@
+//
+//  SavedJobEntity.swift
+//  indeed_ai_job_search
+//
+//  Phase 5 — SwiftData entity for persisted saved jobs (deduped by indeedJobKey).
+//
+
+import Foundation
+import SwiftData
+
+@Model
+final class SavedJobEntity {
+    @Attribute(.unique) var indeedJobKey: String
+    var listingJSON: Data
+    var savedAt: Date
+    var lastVerifiedAt: Date?
+
+    init(
+        indeedJobKey: String,
+        listingJSON: Data,
+        savedAt: Date = .now,
+        lastVerifiedAt: Date? = nil
+    ) {
+        self.indeedJobKey = indeedJobKey
+        self.listingJSON = listingJSON
+        self.savedAt = savedAt
+        self.lastVerifiedAt = lastVerifiedAt
+    }
+
+    convenience init(listing: JobListing, savedAt: Date = .now, lastVerifiedAt: Date? = nil) throws {
+        let data = try Self.encode(listing)
+        self.init(
+            indeedJobKey: listing.indeedJobKey,
+            listingJSON: data,
+            savedAt: savedAt,
+            lastVerifiedAt: lastVerifiedAt
+        )
+    }
+
+    func listing() throws -> JobListing {
+        try Self.decode(listingJSON)
+    }
+
+    func setListing(_ listing: JobListing) throws {
+        indeedJobKey = listing.indeedJobKey
+        listingJSON = try Self.encode(listing)
+    }
+
+    func asSavedJob() throws -> SavedJob {
+        SavedJob(
+            listing: try listing(),
+            savedAt: savedAt,
+            lastVerifiedAt: lastVerifiedAt
+        )
+    }
+
+    private static let encoder = JSONEncoder()
+    private static let decoder = JSONDecoder()
+
+    private static func encode(_ listing: JobListing) throws -> Data {
+        try encoder.encode(listing)
+    }
+
+    private static func decode(_ data: Data) throws -> JobListing {
+        try decoder.decode(JobListing.self, from: data)
+    }
+}

+ 66 - 0
indeed_ai_job_search/Persistence/SavedJobsRevalidator.swift

@@ -0,0 +1,66 @@
+//
+//  SavedJobsRevalidator.swift
+//  indeed_ai_job_search
+//
+//  Phase 5 — Revalidate stale saved jobs (default 24h) without removing them.
+//
+
+import Foundation
+
+@MainActor
+final class SavedJobsRevalidator {
+    /// Matches Phase 4 cache policy reserved for saved verification.
+    static let defaultStaleInterval: TimeInterval = 86_400
+
+    private let store: any SavedJobsStore
+    private let validationService: JobValidationService
+    private let staleInterval: TimeInterval
+    private var inFlightTask: Task<Void, Never>?
+
+    init(
+        store: any SavedJobsStore,
+        validationService: JobValidationService,
+        staleInterval: TimeInterval = SavedJobsRevalidator.defaultStaleInterval
+    ) {
+        self.store = store
+        self.validationService = validationService
+        self.staleInterval = staleInterval
+    }
+
+    /// Starts a background pass if one is not already running.
+    func revalidateIfNeeded() {
+        guard inFlightTask == nil else { return }
+        inFlightTask = Task { [weak self] in
+            await self?.runPass()
+            self?.inFlightTask = nil
+        }
+    }
+
+    func cancel() {
+        inFlightTask?.cancel()
+        inFlightTask = nil
+    }
+
+    /// Returns how many listings were checked (useful for tests).
+    @discardableResult
+    func runPass(now: Date = .now) async -> Int {
+        let stale = store.savedJobs.filter { saved in
+            guard let verified = saved.lastVerifiedAt else { return true }
+            return now.timeIntervalSince(verified) >= staleInterval
+        }
+
+        guard !stale.isEmpty else { return 0 }
+
+        var checked = 0
+        for saved in stale {
+            if Task.isCancelled { break }
+
+            var listing = saved.listing
+            let availability = await validationService.validate(listing: listing)
+            listing.availability = availability
+            store.updateListing(listing, lastVerifiedAt: now)
+            checked += 1
+        }
+        return checked
+    }
+}

+ 148 - 0
indeed_ai_job_search/Persistence/SwiftDataSavedJobsStore.swift

@@ -0,0 +1,148 @@
+//
+//  SwiftDataSavedJobsStore.swift
+//  indeed_ai_job_search
+//
+//  Phase 5 — Persisted SavedJobsStore with indeedJobKey dedupe.
+//
+
+import Foundation
+import Observation
+import SwiftData
+
+@Observable
+@MainActor
+final class SwiftDataSavedJobsStore: SavedJobsStore {
+    private let context: ModelContext
+    private(set) var savedJobs: [SavedJob] = []
+
+    init(context: ModelContext) {
+        self.context = context
+        reload()
+    }
+
+    convenience init(controller: PersistenceController) {
+        self.init(context: controller.mainContext)
+    }
+
+    func isSaved(_ listing: JobListing) -> Bool {
+        savedJobs.contains { $0.listing.indeedJobKey == listing.indeedJobKey }
+    }
+
+    func toggleSave(_ listing: JobListing) {
+        if isSaved(listing) {
+            remove(listing)
+        } else {
+            save(listing)
+        }
+    }
+
+    func save(_ listing: JobListing) {
+        let key = listing.indeedJobKey
+        if let existing = fetchEntity(key: key) {
+            // Dedupe: keep original savedAt; refresh snapshot fields.
+            do {
+                try existing.setListing(listing)
+                try context.save()
+                reload()
+            } catch {
+                context.rollback()
+            }
+            return
+        }
+
+        do {
+            let entity = try SavedJobEntity(listing: listing, savedAt: .now)
+            context.insert(entity)
+            try context.save()
+            reload()
+        } catch {
+            context.rollback()
+        }
+    }
+
+    func remove(_ listing: JobListing) {
+        remove(indeedJobKey: listing.indeedJobKey)
+    }
+
+    func remove(indeedJobKey: String) {
+        guard let entity = fetchEntity(key: indeedJobKey) else { return }
+        context.delete(entity)
+        do {
+            try context.save()
+            reload()
+        } catch {
+            context.rollback()
+            reload()
+        }
+    }
+
+    func updateListing(_ listing: JobListing, lastVerifiedAt: Date) {
+        guard let entity = fetchEntity(key: listing.indeedJobKey) else { return }
+        do {
+            try entity.setListing(listing)
+            entity.lastVerifiedAt = lastVerifiedAt
+            try context.save()
+            reload()
+        } catch {
+            context.rollback()
+        }
+    }
+
+    func clearAll() {
+        let entities = fetchAllEntities()
+        for entity in entities {
+            context.delete(entity)
+        }
+        do {
+            try context.save()
+            reload()
+        } catch {
+            context.rollback()
+            reload()
+        }
+    }
+
+    func exportJSON() throws -> Data {
+        let encoder = JSONEncoder()
+        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+        encoder.dateEncodingStrategy = .iso8601
+        let payloads = savedJobs.map { ExportSavedJob(from: $0) }
+        return try encoder.encode(payloads)
+    }
+
+    func reload() {
+        let entities = fetchAllEntities()
+        savedJobs = entities.compactMap { entity in
+            try? entity.asSavedJob()
+        }
+        .sorted { $0.savedAt > $1.savedAt }
+    }
+
+    private func fetchEntity(key: String) -> SavedJobEntity? {
+        let predicate = #Predicate<SavedJobEntity> { $0.indeedJobKey == key }
+        var descriptor = FetchDescriptor<SavedJobEntity>(predicate: predicate)
+        descriptor.fetchLimit = 1
+        return try? context.fetch(descriptor).first
+    }
+
+    private func fetchAllEntities() -> [SavedJobEntity] {
+        let descriptor = FetchDescriptor<SavedJobEntity>(
+            sortBy: [SortDescriptor(\.savedAt, order: .reverse)]
+        )
+        return (try? context.fetch(descriptor)) ?? []
+    }
+}
+
+private struct ExportSavedJob: Codable {
+    var indeedJobKey: String
+    var savedAt: Date
+    var lastVerifiedAt: Date?
+    var listing: JobListing
+
+    init(from saved: SavedJob) {
+        indeedJobKey = saved.listing.indeedJobKey
+        savedAt = saved.savedAt
+        lastVerifiedAt = saved.lastVerifiedAt
+        listing = saved.listing
+    }
+}

+ 12 - 0
indeed_ai_job_search/Services/Mock/MockServices.swift

@@ -119,6 +119,18 @@ final class MockSavedJobsStore: SavedJobsStore {
     func remove(_ listing: JobListing) {
         savedJobs.removeAll { $0.listing.indeedJobKey == listing.indeedJobKey }
     }
+
+    func updateListing(_ listing: JobListing, lastVerifiedAt: Date) {
+        guard let index = savedJobs.firstIndex(where: { $0.listing.indeedJobKey == listing.indeedJobKey }) else {
+            return
+        }
+        savedJobs[index].listing = listing
+        savedJobs[index].lastVerifiedAt = lastVerifiedAt
+    }
+
+    func clearAll() {
+        savedJobs.removeAll()
+    }
 }
 
 @Observable

+ 165 - 0
indeed_ai_job_searchTests/SavedJobsPersistenceTests.swift

@@ -0,0 +1,165 @@
+//
+//  SavedJobsPersistenceTests.swift
+//  indeed_ai_job_searchTests
+//
+
+import XCTest
+@testable import indeed_ai_job_search
+
+@MainActor
+final class SwiftDataSavedJobsStoreTests: XCTestCase {
+    private var controller: PersistenceController!
+    private var store: SwiftDataSavedJobsStore!
+
+    override func setUp() {
+        super.setUp()
+        controller = PersistenceController.makeInMemory()
+        store = SwiftDataSavedJobsStore(controller: controller)
+    }
+
+    override func tearDown() {
+        store = nil
+        controller = nil
+        super.tearDown()
+    }
+
+    func testSaveUnsaveSurvivesReload() {
+        let listing = makeListing(key: "abc123def4567890", title: "iOS Developer")
+        store.toggleSave(listing)
+        XCTAssertTrue(store.isSaved(listing))
+        XCTAssertEqual(store.savedJobs.count, 1)
+
+        let reloaded = SwiftDataSavedJobsStore(controller: controller)
+        XCTAssertTrue(reloaded.isSaved(listing))
+        XCTAssertEqual(reloaded.savedJobs.first?.listing.title, "iOS Developer")
+
+        reloaded.toggleSave(listing)
+        XCTAssertFalse(reloaded.isSaved(listing))
+        XCTAssertTrue(reloaded.savedJobs.isEmpty)
+    }
+
+    func testDedupeOnSaveKeepsSingleRow() {
+        let first = makeListing(key: "samekey000000001", title: "Old Title")
+        var second = makeListing(key: "samekey000000001", title: "New Title")
+        second.company = "Acme"
+
+        store.toggleSave(first)
+        let savedAt = store.savedJobs.first?.savedAt
+        store.save(second)
+
+        XCTAssertEqual(store.savedJobs.count, 1)
+        XCTAssertEqual(store.savedJobs.first?.listing.title, "New Title")
+        XCTAssertEqual(store.savedJobs.first?.listing.company, "Acme")
+        XCTAssertEqual(store.savedJobs.first?.savedAt, savedAt)
+    }
+
+    func testUpdateListingPersistsAvailability() {
+        var listing = makeListing(key: "verifykey0000001", title: "Swift Engineer")
+        store.toggleSave(listing)
+        listing.availability = .unavailable
+
+        let verifiedAt = Date(timeIntervalSince1970: 1_700_000_000)
+        store.updateListing(listing, lastVerifiedAt: verifiedAt)
+
+        XCTAssertEqual(store.savedJobs.first?.availability, .unavailable)
+        XCTAssertEqual(store.savedJobs.first?.lastVerifiedAt, verifiedAt)
+        XCTAssertFalse(store.savedJobs.isEmpty)
+    }
+
+    func testClearAllRemovesPersistedJobs() {
+        store.toggleSave(makeListing(key: "clear00000000001", title: "A"))
+        store.toggleSave(makeListing(key: "clear00000000002", title: "B"))
+        XCTAssertEqual(store.savedJobs.count, 2)
+
+        store.clearAll()
+        XCTAssertTrue(store.savedJobs.isEmpty)
+
+        let reloaded = SwiftDataSavedJobsStore(controller: controller)
+        XCTAssertTrue(reloaded.savedJobs.isEmpty)
+    }
+
+    func testExportJSONContainsKeys() throws {
+        store.toggleSave(makeListing(key: "exportkey0000001", title: "Export Me"))
+        let data = try store.exportJSON()
+        let text = String(data: data, encoding: .utf8) ?? ""
+        XCTAssertTrue(text.contains("exportkey0000001"))
+        XCTAssertTrue(text.contains("Export Me"))
+    }
+}
+
+@MainActor
+final class SavedJobsRevalidatorTests: XCTestCase {
+    func testRevalidatesOnlyStaleJobs() async {
+        let store = MockSavedJobsStore()
+        var fresh = makeListing(key: "fresh00000000001", title: "Fresh")
+        fresh.availability = .open
+        var stale = makeListing(key: "stale00000000001", title: "Stale")
+        stale.availability = .open
+
+        store.toggleSave(fresh)
+        store.toggleSave(stale)
+        store.updateListing(fresh, lastVerifiedAt: Date())
+        store.updateListing(stale, lastVerifiedAt: Date().addingTimeInterval(-86_400 * 2))
+
+        let validation = StubValidationService(result: .unavailable)
+        let revalidator = SavedJobsRevalidator(
+            store: store,
+            validationService: validation,
+            staleInterval: 86_400
+        )
+
+        let checked = await revalidator.runPass()
+        XCTAssertEqual(checked, 1)
+        XCTAssertEqual(
+            store.savedJobs.first(where: { $0.listing.indeedJobKey == "stale00000000001" })?.availability,
+            .unavailable
+        )
+        XCTAssertEqual(
+            store.savedJobs.first(where: { $0.listing.indeedJobKey == "fresh00000000001" })?.availability,
+            .open
+        )
+    }
+
+    func testNeverVerifiedJobsAreChecked() async {
+        let store = MockSavedJobsStore()
+        store.toggleSave(makeListing(key: "never00000000001", title: "Never"))
+
+        let validation = StubValidationService(result: .unknown)
+        let revalidator = SavedJobsRevalidator(
+            store: store,
+            validationService: validation,
+            staleInterval: 86_400
+        )
+
+        let checked = await revalidator.runPass()
+        XCTAssertEqual(checked, 1)
+        XCTAssertNotNil(store.savedJobs.first?.lastVerifiedAt)
+        XCTAssertEqual(store.savedJobs.first?.availability, .unknown)
+    }
+}
+
+private struct StubValidationService: JobValidationService {
+    let result: JobAvailability
+
+    func validate(listing: JobListing) async -> JobAvailability {
+        result
+    }
+}
+
+private func makeListing(
+    key: String,
+    title: String,
+    company: String = "Test Co",
+    postedAt: Date = Date()
+) -> JobListing {
+    JobListing(
+        indeedJobKey: key,
+        title: title,
+        company: company,
+        location: "Remote",
+        remoteType: .remote,
+        postedAt: postedAt,
+        snippet: "Test snippet",
+        indeedURL: URL(string: "https://www.indeed.com/viewjob?jk=\(key)")!
+    )
+}