// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // RecentsStore.swift — historique des éléments consultés, recherches // sauvegardées et ALERTES honnêtes : à l'ouverture, on recompte le total réel // de chaque recherche sauvegardée via l'API et on affiche le delta (« +12 // nouveautés ») — jamais de faux chiffre. Persistance JSON locale. import Foundation import SwiftUI struct SavedSearch: Identifiable, Codable, Hashable { var id: UUID = UUID() var name: String var universeID: String // "tous" = recherche universelle var query: String var params: [String: String] = [:] var alertsOn: Bool = true var lastTotal: Int? var newCount: Int = 0 var lastChecked: Date? } @MainActor final class RecentsStore: ObservableObject { static let shared = RecentsStore() @Published private(set) var viewed: [KAItem] = [] { didSet { save() } } @Published var savedSearches: [SavedSearch] = [] { didSet { save() } } var alertCount: Int { savedSearches.filter(\.alertsOn).map(\.newCount).reduce(0, +) } private var fileURL: URL { let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir.appendingPathComponent("ka-recents.json") } private struct Snapshot: Codable { var viewed: [KAItem] var searches: [SavedSearch] } init() { if let data = try? Data(contentsOf: fileURL), let s = try? JSONDecoder().decode(Snapshot.self, from: data) { viewed = s.viewed savedSearches = s.searches } } private func save() { if let data = try? JSONEncoder().encode(Snapshot(viewed: viewed, searches: savedSearches)) { try? data.write(to: fileURL, options: .atomic) } } // MARK: historique consulté func record(_ item: KAItem) { viewed.removeAll { $0.id == item.id } viewed.insert(item, at: 0) if viewed.count > 30 { viewed.removeLast(viewed.count - 30) } } func clearHistory() { viewed = [] } // MARK: recherches sauvegardées + alertes func saveSearch(name: String, universeID: String, query: String, params: [String: String]) { var s = SavedSearch(name: name.isEmpty ? query : name, universeID: universeID, query: query, params: params) savedSearches.insert(s, at: 0) Haptics.success() // total initial en arrière-plan Task { [weak self] in guard let self else { return } if let u = Ecosystem.universe(universeID) { s.lastTotal = await UniverseService.total(u, query: query, params: params) s.lastChecked = .now if let i = self.savedSearches.firstIndex(where: { $0.id == s.id }) { self.savedSearches[i] = s } } } } func remove(_ id: UUID) { savedSearches.removeAll { $0.id == id } } func markSeen(_ id: UUID) { guard let i = savedSearches.firstIndex(where: { $0.id == id }) else { return } savedSearches[i].lastTotal = (savedSearches[i].lastTotal ?? 0) + savedSearches[i].newCount savedSearches[i].newCount = 0 } /// Recompte chaque recherche (deltas réels). Appelé à l'ouverture de l'app. func refreshAlerts() async { for idx in savedSearches.indices { let s = savedSearches[idx] guard s.alertsOn, let u = Ecosystem.universe(s.universeID) else { continue } if let total = await UniverseService.total(u, query: s.query, params: s.params) { if let last = s.lastTotal, total > last { savedSearches[idx].newCount = total - last } else if s.lastTotal == nil { savedSearches[idx].lastTotal = total } savedSearches[idx].lastChecked = .now } } } } // MARK: - Collections éditoriales (requêtes RÉELLES, aucun contenu inventé) struct EditorialCollection: Identifiable { let id: String let title: String let subtitle: String let symbol: String let universeID: String let query: String? let params: [String: String] } enum Editorial { static let collections: [EditorialCollection] = [ .init(id: "week-end", title: "Le week-end s'organise", subtitle: "Sorties gratuites à venir partout au Québec", symbol: "party.popper.fill", universeID: "sorti-ka", query: nil, params: ["free": "true"]), .init(id: "aubaines-epicerie", title: "Le panier futé", subtitle: "Les soldes d'épicerie du moment", symbol: "tag.fill", universeID: "food-ka", query: nil, params: ["on_sale": "true"]), .init(id: "premiere-auto", title: "Première auto", subtitle: "Des véhicules récents sous 15 000 $", symbol: "car.2.fill", universeID: "auto-ka", query: nil, params: ["price_max": "15000", "year_min": "2015"]), .init(id: "quatre-et-demi", title: "Le classique 4½", subtitle: "Les 4½ fraîchement affichés", symbol: "key.fill", universeID: "lou-ka", query: nil, params: ["unit_type": "4½"]), ] }