SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%

v3.1.0 — REFONTE MAJEURE : personnalisation locale (profil de préférences + feed « Pour vous » immersif), fiches nouvelle génération (héros photo plein cadre + carte de contenu + barre d'action flottante), Explorer en bandes éditoriales, recherche redessinée (manchette + tuiles d'idées par univers), agenda Sorti·Ka par horizon temporel, chips métier vérifiées live (fix cuisine=sushi→sushi-japonais + work_mode + category), hypothèque Immo·Ka, favoris filtres/tri/recherche, pouls en mosaïque, couches carte persistées + couche Favoris, section Personnalisation (Loi 25 : local, désactivable, effaçable), dock en espace réservé (plus jamais de contenu caché) — 27 tests verts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 28 days ago (Aug 26, 2026) parent 4972097

17 changed files +1,525 −248

modified KA/App/KAApp.swift +21 −14
@@ -52,25 +52,32 @@ struct RootView: View {
52 52 _loaded = State(initialValue: [initial])
53 53 }
54 54
55 + /// Le dock s'efface dans les modes immersifs de Trajet
56 + /// (conduite et Découvrir plein écran)
57 + private var dockVisible: Bool { !trajet.navigating && !trajet.discovering }
58 + @Environment(\.colorScheme) private var scheme
59 +
55 60 var body: some View {
56 − ZStack {
57 − pane(0) { HomeView() }
58 − pane(1) { SearchView() }
59 − pane(2) { UniversesView() }
60 − pane(3) { MapTab() }
61 − pane(4) { TrajetView().environmentObject(trajet) }
62 − pane(5) { MoreTab() }
63 − }
64 − .tint(KATheme.green)
65 − .safeAreaInset(edge: .bottom) {
66 − // le dock s'efface dans les modes immersifs de Trajet
67 − // (conduite et Découvrir plein écran)
68 − if !trajet.navigating && !trajet.discovering {
61 + // le dock occupe SON espace, sous le contenu : rien ne passe jamais
62 + // dessous, sur aucune page — c'est une vraie barre, pas un flotteur
63 + VStack(spacing: 0) {
64 + ZStack {
65 + pane(0) { HomeView() }
66 + pane(1) { SearchView() }
67 + pane(2) { UniversesView() }
68 + pane(3) { MapTab() }
69 + pane(4) { TrajetView().environmentObject(trajet) }
70 + pane(5) { MoreTab() }
71 + }
72 + if dockVisible {
69 73 KADock(tab: $tab, alertCount: recents.alertCount) { showAgent = true }
70 − .padding(.bottom, 4)
74 + .padding(.top, 8)
75 + .padding(.bottom, 2)
71 76 .transition(.move(edge: .bottom).combined(with: .opacity))
72 77 }
73 78 }
79 + .background(KATheme.paper(scheme).ignoresSafeArea())
80 + .tint(KATheme.green)
74 81 .animation(.spring(response: 0.4, dampingFraction: 0.8), value: trajet.navigating)
75 82 .animation(.spring(response: 0.4, dampingFraction: 0.8), value: trajet.discovering)
76 83 .onChange(of: tab) { _, t in loaded.insert(t) }
modified KA/Core/Ecosystem.swift +12 −2
@@ -74,6 +74,11 @@ struct KAItem: Identifiable, Hashable, Codable {
74 74 var tags: [String]? = nil
75 75 /// Résumé « en bref » de la fiche (digest du site)
76 76 var brief: String? = nil
77 + /// Prix NUMÉRIQUE (tri, gamme de prix du profil, hypothèque) — priceLabel
78 + /// reste l'affichage. Optionnel : les favoris sérialisés avant restent lisibles.
79 + var price: Double? = nil
80 + /// Date ISO « yyyy-MM-dd » (événements Sorti·Ka — regroupement temporel)
81 + var date: String? = nil
77 82
78 83 struct Fact: Hashable, Codable { var label: String; var value: String }
79 84 }
@@ -160,7 +165,7 @@ enum Ecosystem {
160 165 map: { o in
161 166 var it = base(o, universe: "lou-ka")
162 167 it.subtitle = [o.str("unit_type"), o.str("address")].compactMap { $0 }.joined(separator: " · ")
163 − if let p = o.num("price"), p > 50 { it.priceLabel = p.money0 + "/mois" }
168 + if let p = o.num("price"), p > 50 { it.priceLabel = p.money0 + "/mois"; it.price = p }
164 169 it.facts = [
165 170 o.str("unit_type").map { .init(label: "Taille", value: $0) },
166 171 o.str("address").map { .init(label: "Adresse", value: $0) },
@@ -178,7 +183,7 @@ enum Ecosystem {
178 183 map: { o in
179 184 var it = base(o, universe: "immo-ka")
180 185 it.subtitle = [o.str("property_type"), o.str("region")].compactMap { $0 }.joined(separator: " · ")
181 − if let p = o.num("price"), p > 1000 { it.priceLabel = p.money0 }
186 + if let p = o.num("price"), p > 1000 { it.priceLabel = p.money0; it.price = p }
182 187 it.facts = [
183 188 o.num("bedrooms").map { .init(label: "Chambres", value: String(Int($0))) },
184 189 o.num("bathrooms").map { .init(label: "Salles de bain", value: String(Int($0))) },
@@ -202,6 +207,7 @@ enum Ecosystem {
202 207 var it = base(o, universe: "auto-ka")
203 208 it.subtitle = [o.num("year").map { String(Int($0)) }, o.str("mileage_label")].compactMap { $0 }.joined(separator: " · ")
204 209 it.priceLabel = o.str("price_label") ?? o.num("price").map(\.money0)
210 + it.price = o.num("price")
205 211 it.facts = [
206 212 o.str("make").map { .init(label: "Marque", value: $0) },
207 213 o.str("model").map { .init(label: "Modèle", value: $0) },
@@ -223,6 +229,7 @@ enum Ecosystem {
223 229 var it = base(o, universe: "fabri-ka")
224 230 it.subtitle = o.str("store_id", "store")
225 231 it.priceLabel = o.str("price_label") ?? o.num("price").map(\.money2)
232 + it.price = o.num("price")
226 233 it.facts = [o.str("category").map { .init(label: "Catégorie", value: $0) }].compactMap { $0 }
227 234 return it
228 235 }),
@@ -239,6 +246,7 @@ enum Ecosystem {
239 246 if onSale, let reg = o.num("regular_price"), reg > p {
240 247 it.priceLabel = "\(p.money2) 🏷️ (rég. \(reg.money2))"
241 248 } else { it.priceLabel = p.money2 }
249 + it.price = p
242 250 }
243 251 it.facts = [
244 252 o.str("category").map { .init(label: "Catégorie", value: $0) },
@@ -269,6 +277,7 @@ enum Ecosystem {
269 277 statTotalKeys: ["total_active", "total"],
270 278 map: { o in
271 279 var it = base(o, universe: "sorti-ka")
280 + it.date = o.str("start_date")
272 281 it.subtitle = [o.str("start_date"), o.str("venue")].compactMap { $0 }.joined(separator: " · ")
273 282 it.facts = [
274 283 o.str("start_date").map { .init(label: "Début", value: $0) },
@@ -315,6 +324,7 @@ enum Ecosystem {
315 324 let sMin = o.num("salary_year_min"), sMax = o.num("salary_year_max")
316 325 if let a = sMin, a > 20000 {
317 326 it.priceLabel = (sMax != nil && sMax! > a) ? "\(a.money0)–\(sMax!.money0)/an" : a.money0 + "/an"
327 + it.price = a
318 328 } else if let h = o.num("salary_hour_min"), h > 10 {
319 329 it.priceLabel = h.money2 + "/h"
320 330 }
modified KA/Core/FavoritesStore.swift +2 −0
@@ -54,9 +54,11 @@ final class FavoritesStore: ObservableObject {
54 54 for i in collections.indices {
55 55 collections[i].items.removeAll { $0.id == item.id }
56 56 }
57 + PersonalizationStore.shared.record(.unfavorite, item: item)
57 58 } else {
58 59 let idx = collections.firstIndex { $0.id == collectionID } ?? 0
59 60 collections[idx].items.insert(item, at: 0)
61 + PersonalizationStore.shared.record(.favorite, item: item)
60 62 }
61 63 Haptics.tap()
62 64 }
modified KA/Core/KAFilters.swift +107 −1
@@ -38,17 +38,20 @@ enum FilterCatalog {
38 38 ]
39 39 case "job-ka": return [
40 40 .init(id: "city", label: "Ville", kind: .text(placeholder: "Montréal, Québec…")),
41 + .init(id: "work_mode", label: "Mode de travail", kind: .options(["teletravail", "hybride", "presentiel"])),
41 42 ]
42 43 case "food-ka": return [
43 44 .init(id: "on_sale", label: "En solde 🏷️", kind: .toggle(value: "true")),
44 45 ]
45 46 case "resto-ka": return [
46 47 .init(id: "city", label: "Ville", kind: .text(placeholder: "Montréal…")),
47 − .init(id: "cuisine", label: "Cuisine", kind: .options(["italien", "quebecois", "sushi", "bbq-grillades", "mexicain", "indien", "vegetarien", "dejeuner"])),
48 + // slugs RÉELS de l'API (cuisine=sushi retournait 0 — vérifié live 2026-08-26)
49 + .init(id: "cuisine", label: "Cuisine", kind: .options(["italien", "quebecois", "sushi-japonais", "pizza", "burgers", "bbq-grillades", "mexicain", "indien", "chinois", "fruits-de-mer", "vegetarien-vegan", "dejeuner-brunch", "cafe-dessert", "poulet"])),
48 50 ]
49 51 case "sorti-ka": return [
50 52 .init(id: "city", label: "Ville", kind: .text(placeholder: "Sherbrooke…")),
51 53 .init(id: "region", label: "Région", kind: .options(["Montréal", "Capitale-Nationale", "Montérégie", "Estrie", "Laurentides", "Outaouais", "Mauricie"])),
54 + .init(id: "category", label: "Catégorie", kind: .options(["musique", "festival", "famille", "sport", "exposition-musee", "arts-scene", "plein-air", "cinema"])),
52 55 .init(id: "free", label: "Gratuit 🎉", kind: .toggle(value: "true")),
53 56 ]
54 57 default: return []
@@ -56,6 +59,109 @@ enum FilterCatalog {
56 59 }
57 60 }
58 61
62 +// MARK: - Chips métier rapides — l'ADN de chaque univers, en un tap
63 +// (paramètres et valeurs VÉRIFIÉS live sur les API le 2026-08-26)
64 +
65 +struct QuickFilter: Identifiable, Hashable {
66 + let label: String
67 + let params: [String: String]
68 + var id: String { label }
69 +}
70 +
71 +enum QuickFilterCatalog {
72 + static func chips(for universeID: String) -> [QuickFilter] {
73 + switch universeID {
74 + case "lou-ka": return [
75 + .init(label: "3½", params: ["unit_type": "3½"]),
76 + .init(label: "4½", params: ["unit_type": "4½"]),
77 + .init(label: "5½", params: ["unit_type": "5½"]),
78 + .init(label: "≤ 1 000 $", params: ["price_max": "1000"]),
79 + .init(label: "≤ 1 500 $", params: ["price_max": "1500"]),
80 + ]
81 + case "immo-ka": return [
82 + .init(label: "≤ 300 k$", params: ["price_max": "300000"]),
83 + .init(label: "300–500 k$", params: ["price_min": "300000", "price_max": "500000"]),
84 + .init(label: "500 k$ +", params: ["price_min": "500000"]),
85 + ]
86 + case "auto-ka": return [
87 + .init(label: "Toyota", params: ["make": "Toyota"]),
88 + .init(label: "Honda", params: ["make": "Honda"]),
89 + .init(label: "Hyundai", params: ["make": "Hyundai"]),
90 + .init(label: "Mazda", params: ["make": "Mazda"]),
91 + .init(label: "≤ 15 000 $", params: ["price_max": "15000"]),
92 + ]
93 + case "resto-ka": return [
94 + .init(label: "Italien", params: ["cuisine": "italien"]),
95 + .init(label: "Sushi", params: ["cuisine": "sushi-japonais"]),
96 + .init(label: "Pizza", params: ["cuisine": "pizza"]),
97 + .init(label: "Québécois", params: ["cuisine": "quebecois"]),
98 + .init(label: "Brunch", params: ["cuisine": "dejeuner-brunch"]),
99 + .init(label: "Végé", params: ["cuisine": "vegetarien-vegan"]),
100 + ]
101 + case "sorti-ka": return [
102 + .init(label: "Musique", params: ["category": "musique"]),
103 + .init(label: "Festival", params: ["category": "festival"]),
104 + .init(label: "Famille", params: ["category": "famille"]),
105 + .init(label: "Sport", params: ["category": "sport"]),
106 + .init(label: "Expos", params: ["category": "exposition-musee"]),
107 + ]
108 + case "job-ka": return [
109 + .init(label: "Télétravail", params: ["work_mode": "teletravail"]),
110 + .init(label: "Hybride", params: ["work_mode": "hybride"]),
111 + .init(label: "Présentiel", params: ["work_mode": "presentiel"]),
112 + ]
113 + default: return []
114 + }
115 + }
116 +}
117 +
118 +/// Rangée de chips métier (sélection exclusive, fusionnée aux params API)
119 +struct UniverseQuickChips: View {
120 + let universe: Universe
121 + @Binding var params: [String: String]
122 + let onApply: () -> Void
123 + @Environment(\.colorScheme) private var scheme
124 +
125 + private var chips: [QuickFilter] { QuickFilterCatalog.chips(for: universe.id) }
126 +
127 + private func isOn(_ chip: QuickFilter) -> Bool {
128 + chip.params.allSatisfy { params[$0.key] == $0.value }
129 + }
130 +
131 + var body: some View {
132 + if !chips.isEmpty {
133 + ScrollView(.horizontal, showsIndicators: false) {
134 + HStack(spacing: 8) {
135 + ForEach(chips) { chip in
136 + let on = isOn(chip)
137 + Button {
138 + Haptics.tap()
139 + // exclusif au sein du catalogue : on retire les
140 + // clés des autres chips avant d'appliquer
141 + for other in chips {
142 + for k in other.params.keys { params.removeValue(forKey: k) }
143 + }
144 + if !on { params.merge(chip.params) { _, new in new } }
145 + onApply()
146 + } label: {
147 + Text(chip.label)
148 + .font(.system(.caption, design: .rounded, weight: .bold))
149 + .padding(.horizontal, 12).padding(.vertical, 8)
150 + .background(on ? universe.accent : KATheme.surface(scheme), in: Capsule())
151 + .foregroundStyle(on ? .white : KATheme.ink(scheme))
152 + .overlay(Capsule().strokeBorder(
153 + on ? universe.accent : KATheme.ink(scheme).opacity(0.3), lineWidth: 1.2))
154 + }
155 + .accessibilityLabel("\(on ? "Retirer le filtre" : "Filtrer :") \(chip.label)")
156 + .accessibilityAddTraits(on ? [.isSelected] : [])
157 + }
158 + }
159 + .padding(.vertical, 2)
160 + }
161 + }
162 + }
163 +}
164 +
59 165 // MARK: - Barre + feuille de filtres
60 166
61 167 struct FilterBar: View {
added KA/Core/Personalization.swift +191 −0
@@ -0,0 +1,191 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Personalization.swift — le cerveau local de la super-app : chaque geste
3 +// (fiche ouverte, favori, recherche, lien source) devient un signal, agrégé
4 +// avec décroissance temporelle en un PROFIL DE PRÉFÉRENCES (univers suivis,
5 +// villes, gamme de prix, mots-clés) qui alimente le feed « Pour vous », la
6 +// recherche et l'accueil. 100 % sur l'appareil — rien n'est envoyé au serveur,
7 +// désactivable et effaçable dans Profil (esprit Loi 25).
8 +import Foundation
9 +import SwiftUI
10 +
11 +// MARK: - Signaux
12 +
13 +enum InteractionKind: String, Codable {
14 + case view // fiche consultée
15 + case favorite // ajout aux favoris (signal fort)
16 + case unfavorite // retrait (signal négatif)
17 + case search // recherche soumise
18 + case openSource // sortie vers l'annonce originale (signal très fort)
19 +}
20 +
21 +struct InteractionEvent: Codable {
22 + var kind: InteractionKind
23 + var universeID: String?
24 + var city: String?
25 + var price: Double?
26 + var tags: [String] = []
27 + var query: String?
28 + var date: Date = .now
29 +}
30 +
31 +// MARK: - Profil de préférences (calcul pur, testé dans KATests)
32 +
33 +struct PreferenceProfile {
34 + /// Affinité par univers, normalisée 0…1
35 + var affinity: [String: Double] = [:]
36 + /// Villes les plus consultées, de la plus forte à la plus faible
37 + var topCities: [String] = []
38 + /// Gamme de prix consultée par univers (autour de la médiane pondérée)
39 + var priceBands: [String: ClosedRange<Double>] = [:]
40 + /// Mots-clés récurrents (étiquettes de fiches + termes de recherche)
41 + var topTags: [String] = []
42 + var eventCount: Int = 0
43 +
44 + /// Assez de signal pour personnaliser sans dire n'importe quoi
45 + var hasSignals: Bool { eventCount >= 5 && !affinity.isEmpty }
46 +
47 + func topUniverses(_ n: Int) -> [String] {
48 + affinity.sorted { $0.value > $1.value }.prefix(n).map(\.key)
49 + }
50 +}
51 +
52 +enum PreferenceEngine {
53 + /// Poids d'un geste (le favori et la sortie source pèsent plus qu'une vue)
54 + static func weight(_ kind: InteractionKind) -> Double {
55 + switch kind {
56 + case .view: return 1.0
57 + case .favorite: return 3.0
58 + case .unfavorite: return -3.0 // le retrait annule complètement le favori
59 + case .search: return 1.5
60 + case .openSource: return 2.0
61 + }
62 + }
63 +
64 + /// Décroissance : un geste d'il y a 2 semaines pèse ~37 % d'un geste du jour
65 + static func decay(_ date: Date, now: Date) -> Double {
66 + let days = max(0, now.timeIntervalSince(date) / 86_400)
67 + return exp(-days / 14)
68 + }
69 +
70 + static func computeProfile(_ events: [InteractionEvent], now: Date = .now) -> PreferenceProfile {
71 + var p = PreferenceProfile()
72 + p.eventCount = events.count
73 + var uniScore: [String: Double] = [:]
74 + var cityScore: [String: Double] = [:]
75 + var tagScore: [String: Double] = [:]
76 + var prices: [String: [(Double, Double)]] = [:] // universe → (prix, poids)
77 +
78 + for e in events {
79 + let w = weight(e.kind) * decay(e.date, now: now)
80 + if let u = e.universeID { uniScore[u, default: 0] += w }
81 + if let c = e.city, !c.isEmpty, w > 0 { cityScore[c, default: 0] += w }
82 + for t in e.tags where t.count > 2 && w > 0 {
83 + tagScore[t.lowercased(), default: 0] += w
84 + }
85 + if let q = e.query {
86 + for word in q.lowercased().split(separator: " ") where word.count > 3 {
87 + tagScore[String(word), default: 0] += w * 0.6
88 + }
89 + }
90 + if let u = e.universeID, let pr = e.price, pr > 0, w > 0 {
91 + prices[u, default: []].append((pr, w))
92 + }
93 + }
94 +
95 + if let maxU = uniScore.values.max(), maxU > 0 {
96 + p.affinity = uniScore.filter { $0.value > 0 }.mapValues { $0 / maxU }
97 + }
98 + p.topCities = cityScore.sorted { $0.value > $1.value }.prefix(5).map(\.key)
99 + p.topTags = tagScore.sorted { $0.value > $1.value }.prefix(12).map(\.key)
100 + for (u, list) in prices {
101 + let sorted = list.sorted { $0.0 < $1.0 }
102 + // médiane pondérée : le prix au milieu de la masse des poids
103 + let totalW = sorted.reduce(0) { $0 + $1.1 }
104 + var acc = 0.0
105 + var median = sorted[sorted.count / 2].0
106 + for (price, w) in sorted {
107 + acc += w
108 + if acc >= totalW / 2 { median = price; break }
109 + }
110 + p.priceBands[u] = (median * 0.6)...(median * 1.45)
111 + }
112 + return p
113 + }
114 +
115 + /// Score de recommandation d'un item pour ce profil (plus haut = mieux placé).
116 + static func score(_ item: KAItem, profile: PreferenceProfile) -> Double {
117 + var s = profile.affinity[item.universeID] ?? 0
118 + if let c = item.city, profile.topCities.prefix(3).contains(c) { s += 0.35 }
119 + if let pr = item.price, let band = profile.priceBands[item.universeID],
120 + band.contains(pr) { s += 0.25 }
121 + let haystack = (item.title + " " + (item.subtitle ?? "")).lowercased()
122 + var tagBonus = 0.0
123 + for t in profile.topTags where haystack.contains(t) {
124 + tagBonus += 0.1
125 + if tagBonus >= 0.3 { break }
126 + }
127 + return s + tagBonus
128 + }
129 +}
130 +
131 +// MARK: - Le magasin de signaux (persistance locale, plafonné)
132 +
133 +@MainActor
134 +final class PersonalizationStore: ObservableObject {
135 + static let shared = PersonalizationStore()
136 + static let enabledKey = "ka.perso.enabled"
137 + private static let maxEvents = 600
138 +
139 + @Published private(set) var events: [InteractionEvent] = []
140 + @Published private(set) var profile = PreferenceProfile()
141 +
142 + /// Personnalisation active (défaut : oui) — pilotée depuis Profil.
143 + var enabled: Bool {
144 + UserDefaults.standard.object(forKey: Self.enabledKey) == nil
145 + ? true : UserDefaults.standard.bool(forKey: Self.enabledKey)
146 + }
147 +
148 + private var fileURL: URL {
149 + let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
150 + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
151 + return dir.appendingPathComponent("ka-signaux.json")
152 + }
153 +
154 + init() {
155 + if let data = try? Data(contentsOf: fileURL),
156 + let saved = try? JSONDecoder().decode([InteractionEvent].self, from: data) {
157 + events = saved
158 + }
159 + profile = PreferenceEngine.computeProfile(events)
160 + }
161 +
162 + func record(_ kind: InteractionKind, item: KAItem) {
163 + record(InteractionEvent(kind: kind, universeID: item.universeID,
164 + city: item.city, price: item.price,
165 + tags: Array((item.tags ?? []).prefix(6))))
166 + }
167 +
168 + func recordSearch(_ query: String) {
169 + record(InteractionEvent(kind: .search, universeID: nil, query: query))
170 + }
171 +
172 + func record(_ event: InteractionEvent) {
173 + guard enabled else { return }
174 + events.append(event)
175 + if events.count > Self.maxEvents { events.removeFirst(events.count - Self.maxEvents) }
176 + profile = PreferenceEngine.computeProfile(events)
177 + save()
178 + }
179 +
180 + func reset() {
181 + events = []
182 + profile = PreferenceProfile()
183 + try? FileManager.default.removeItem(at: fileURL)
184 + }
185 +
186 + private func save() {
187 + if let data = try? JSONEncoder().encode(events) {
188 + try? data.write(to: fileURL, options: .atomic)
189 + }
190 + }
191 +}
added KA/Core/Recommendation.swift +144 −0
@@ -0,0 +1,144 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Recommendation.swift — le feed « Pour vous » : à partir du profil de
3 +// préférences (Personalization.swift), on interroge les univers les plus
4 +// suivis avec la ville dominante, on SCORE chaque item côté client (ville,
5 +// gamme de prix, mots-clés) puis on entrelace les univers — jamais un simple
6 +// tri chronologique, jamais de contenu inventé : tout vient des API live.
7 +// Prêt pour mieux plus tard (embeddings, collaboratif) : il suffit de
8 +// remplacer PreferenceEngine.score sans toucher aux vues.
9 +import Foundation
10 +
11 +struct ForYouEntry: Identifiable, Hashable {
12 + let item: KAItem
13 + let reason: String
14 + var id: String { item.id }
15 +}
16 +
17 +enum RecommendationService {
18 + /// Univers où filtrer par ville a du sens (épicerie/produits/créateurs : non)
19 + private static let citySensible: Set<String> = [
20 + "lou-ka", "immo-ka", "resto-ka", "sorti-ka", "job-ka", "auto-ka",
21 + ]
22 +
23 + /// Construit le feed mélangé et classé. `excluding` = déjà vus (historique).
24 + static func feed(profile: PreferenceProfile,
25 + excluding: Set<String> = [],
26 + limit: Int = 10) async -> [ForYouEntry] {
27 + guard profile.hasSignals else { return [] }
28 + let universes = profile.topUniverses(4)
29 + .compactMap(Ecosystem.universe)
30 + .filter { $0.map != nil }
31 + guard !universes.isEmpty else { return [] }
32 + let city = profile.topCities.first
33 +
34 + var pools: [String: [KAItem]] = [:]
35 + await withTaskGroup(of: (String, [KAItem]).self) { group in
36 + for u in universes {
37 + group.addTask {
38 + let c = citySensible.contains(u.id) ? city : nil
39 + var items = (try? await UniverseService.fetch(u, city: c, limit: 14)) ?? []
40 + // ville trop pointue pour cet univers ? on élargit
41 + if items.isEmpty, c != nil {
42 + items = (try? await UniverseService.fetch(u, limit: 14)) ?? []
43 + }
44 + return (u.id, items)
45 + }
46 + }
47 + for await (id, items) in group { pools[id] = items }
48 + }
49 +
50 + // score + tri à l'intérieur de chaque univers
51 + var ranked: [String: [KAItem]] = [:]
52 + for (id, items) in pools {
53 + ranked[id] = items
54 + .filter { !excluding.contains($0.id) }
55 + .sorted { PreferenceEngine.score($0, profile: profile) > PreferenceEngine.score($1, profile: profile) }
56 + }
57 +
58 + // entrelacement pondéré : l'univers dominant place plus de cartes,
59 + // mais le feed reste un MÉLANGE (jamais 10 cartes du même univers)
60 + var feed: [ForYouEntry] = []
61 + var cursor: [String: Int] = [:]
62 + let order = universes.map(\.id)
63 + while feed.count < limit {
64 + var advanced = false
65 + for id in order {
66 + let take = (profile.affinity[id] ?? 0) > 0.66 ? 2 : 1
67 + for _ in 0..<take where feed.count < limit {
68 + let i = cursor[id, default: 0]
69 + guard let pool = ranked[id], i < pool.count else { continue }
70 + let item = pool[i]
71 + cursor[id] = i + 1
72 + feed.append(ForYouEntry(item: item, reason: reason(item, profile: profile)))
73 + advanced = true
74 + }
75 + }
76 + if !advanced { break }
77 + }
78 + return feed
79 + }
80 +
81 + /// Pourquoi cette carte est là — honnête et lisible.
82 + static func reason(_ item: KAItem, profile: PreferenceProfile) -> String {
83 + if let c = item.city, profile.topCities.prefix(3).contains(c) {
84 + return "À \(c), comme vos dernières visites"
85 + }
86 + if let p = item.price, let band = profile.priceBands[item.universeID], band.contains(p) {
87 + return "Dans votre gamme de prix"
88 + }
89 + if let u = Ecosystem.universe(item.universeID) {
90 + return "Parce que vous suivez \(u.wordmark)"
91 + }
92 + return "Suggestion de l'écosystème"
93 + }
94 +}
95 +
96 +// MARK: - Regroupement temporel des événements (Sorti·Ka)
97 +
98 +enum EventBucket: Int, CaseIterable, Comparable {
99 + case today, thisWeek, thisMonth, later, ongoing, undated
100 +
101 + var label: String {
102 + switch self {
103 + case .today: return "Aujourd'hui"
104 + case .thisWeek: return "Cette semaine"
105 + case .thisMonth: return "Ce mois-ci"
106 + case .later: return "Plus tard"
107 + case .ongoing: return "En ce moment"
108 + case .undated: return "À venir"
109 + }
110 + }
111 +
112 + static func < (a: EventBucket, b: EventBucket) -> Bool { a.rawValue < b.rawValue }
113 +
114 + /// Classe une date ISO « yyyy-MM-dd » par rapport à aujourd'hui.
115 + static func bucket(iso: String?, now: Date = .now) -> EventBucket {
116 + guard let iso, iso.count >= 10 else { return .undated }
117 + let fmt = DateFormatter()
118 + fmt.dateFormat = "yyyy-MM-dd"
119 + fmt.locale = Locale(identifier: "fr_CA")
120 + guard let date = fmt.date(from: String(iso.prefix(10))) else { return .undated }
121 + var cal = Calendar(identifier: .gregorian)
122 + cal.locale = Locale(identifier: "fr_CA")
123 + if cal.isDate(date, inSameDayAs: now) { return .today }
124 + if date < now { return .ongoing } // commencé, toujours actif (upcoming=true)
125 + if let week = cal.date(byAdding: .day, value: 7, to: now), date <= week { return .thisWeek }
126 + if let month = cal.date(byAdding: .day, value: 31, to: now), date <= month { return .thisMonth }
127 + return .later
128 + }
129 +}
130 +
131 +// MARK: - Hypothèque (Immo·Ka) — calcul pur, testé
132 +
133 +enum MortgageMath {
134 + /// Paiement mensuel : prix, mise de fonds (fraction 0…1), taux annuel (%), années.
135 + static func monthlyPayment(price: Double, downFraction: Double,
136 + annualRate: Double, years: Int) -> Double {
137 + let principal = price * (1 - downFraction)
138 + guard principal > 0, years > 0 else { return 0 }
139 + let r = annualRate / 100 / 12
140 + let n = Double(years * 12)
141 + guard r > 0 else { return principal / n }
142 + return principal * r * pow(1 + r, n) / (pow(1 + r, n) - 1)
143 + }
144 +}
modified KA/Features/FavoritesView.swift +139 −60
@@ -1,89 +1,168 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 2 // FavoritesView.swift — favoris & collections unifiés (multi-univers dans une
3 −// même collection), consultables hors ligne, partage natif.
3 +// même collection), consultables hors ligne, avec FILTRE par univers, TRI
4 +// (récent / prix) et RECHERCHE dans ses trouvailles.
4 5 import SwiftUI
5 6
6 7 struct FavoritesView: View {
7 8 @EnvironmentObject private var favorites: FavoritesStore
8 9 @State private var newName = ""
9 10 @State private var showNew = false
11 + @State private var filterUniverse: String?
12 + @State private var sort: FavSort = .recent
13 + @State private var searchText = ""
10 14 @Environment(\.colorScheme) private var scheme
11 15
16 + enum FavSort: String, CaseIterable {
17 + case recent = "Récents"
18 + case prixAsc = "Prix ↑"
19 + case prixDesc = "Prix ↓"
20 + case titre = "A → Z"
21 + }
22 +
23 + /// Univers réellement présents dans les favoris (chips de filtre)
24 + private var presentUniverses: [Universe] {
25 + let ids = Set(favorites.allItems.map(\.universeID))
26 + return Ecosystem.all.filter { ids.contains($0.id) }
27 + }
28 +
29 + private func filtered(_ items: [KAItem]) -> [KAItem] {
30 + var out = items
31 + if let f = filterUniverse { out = out.filter { $0.universeID == f } }
32 + if !searchText.isEmpty {
33 + out = out.filter {
34 + $0.title.localizedCaseInsensitiveContains(searchText)
35 + || ($0.city ?? "").localizedCaseInsensitiveContains(searchText)
36 + || ($0.subtitle ?? "").localizedCaseInsensitiveContains(searchText)
37 + }
38 + }
39 + switch sort {
40 + case .recent: return out
41 + case .prixAsc: return out.sorted { ($0.price ?? .greatestFiniteMagnitude) < ($1.price ?? .greatestFiniteMagnitude) }
42 + case .prixDesc: return out.sorted { ($0.price ?? 0) > ($1.price ?? 0) }
43 + case .titre: return out.sorted { $0.title.localizedCompare($1.title) == .orderedAscending }
44 + }
45 + }
46 +
12 47 var body: some View {
13 − NavigationStack {
14 − ScrollView {
15 − VStack(alignment: .leading, spacing: 18) {
16 − if favorites.allItems.isEmpty {
17 − KAEmptyState(symbol: "heart", title: "Aucun favori pour l'instant",
18 − message: "Touchez ♥ sur un logement, une auto, un resto ou une sortie — tout se retrouve ici, même hors ligne.")
19 − }
20 − ForEach(favorites.collections) { collection in
21 − collectionSection(collection)
22 − }
48 + ScrollView {
49 + VStack(alignment: .leading, spacing: 18) {
50 + if favorites.allItems.isEmpty {
51 + KAEmptyState(symbol: "heart", title: "Aucun favori pour l'instant",
52 + message: "Touchez ♥ sur un logement, une auto, un resto ou une sortie — tout se retrouve ici, même hors ligne.")
53 + } else {
54 + universeChips
55 + }
56 + ForEach(favorites.collections) { collection in
57 + collectionSection(collection)
23 58 }
24 − .padding(16)
25 59 }
26 − .background(KATheme.paper(scheme))
27 − .navigationTitle("Favoris")
28 − .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }
29 − .toolbar {
30 − ToolbarItem(placement: .topBarTrailing) {
31 − Button { showNew = true } label: { Image(systemName: "folder.badge.plus") }
32 − .accessibilityLabel("Nouvelle collection")
60 + .padding(16)
61 + }
62 + .background(KATheme.paper(scheme))
63 + .navigationTitle("Favoris")
64 + .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }
65 + .searchable(text: $searchText, prompt: "Chercher dans vos favoris…")
66 + .toolbar {
67 + ToolbarItemGroup(placement: .topBarTrailing) {
68 + if favorites.allItems.count > 1 {
69 + Menu {
70 + Picker("Tri", selection: $sort) {
71 + ForEach(FavSort.allCases, id: \.self) { Text($0.rawValue).tag($0) }
72 + }
73 + } label: { Image(systemName: "arrow.up.arrow.down") }
74 + .accessibilityLabel("Trier les favoris")
33 75 }
76 + Button { showNew = true } label: { Image(systemName: "folder.badge.plus") }
77 + .accessibilityLabel("Nouvelle collection")
78 + }
79 + }
80 + .alert("Nouvelle collection", isPresented: $showNew) {
81 + TextField("Ex. Déménagement à Val-d'Or", text: $newName)
82 + Button("Créer") {
83 + favorites.addCollection(newName)
84 + newName = ""
85 + Haptics.success()
34 86 }
35 − .alert("Nouvelle collection", isPresented: $showNew) {
36 − TextField("Ex. Déménagement à Val-d'Or", text: $newName)
37 − Button("Créer") {
38 − favorites.addCollection(newName)
39 − newName = ""
40 − Haptics.success()
87 + Button("Annuler", role: .cancel) { newName = "" }
88 + } message: {
89 + Text("Une collection peut mélanger tous les univers : un 4½, une auto et un resto ensemble.")
90 + }
91 + }
92 +
93 + private var universeChips: some View {
94 + ScrollView(.horizontal, showsIndicators: false) {
95 + HStack(spacing: 8) {
96 + chip(label: "Tous · \(favorites.allItems.count)", accent: KATheme.green,
97 + on: filterUniverse == nil) { filterUniverse = nil }
98 + ForEach(presentUniverses) { u in
99 + let count = favorites.allItems.filter { $0.universeID == u.id }.count
100 + chip(label: "\(u.wordmark) · \(count)", accent: u.accent,
101 + on: filterUniverse == u.id) {
102 + filterUniverse = filterUniverse == u.id ? nil : u.id
103 + }
41 104 }
42 − Button("Annuler", role: .cancel) { newName = "" }
43 − } message: {
44 − Text("Une collection peut mélanger tous les univers : un 4½, une auto et un resto ensemble.")
45 105 }
46 106 }
47 107 }
48 108
109 + private func chip(label: String, accent: Color, on: Bool, action: @escaping () -> Void) -> some View {
110 + Button {
111 + Haptics.tap()
112 + withAnimation(.snappy) { action() }
113 + } label: {
114 + Text(label)
115 + .font(.system(.caption, design: .monospaced).weight(.bold))
116 + .padding(.horizontal, 11).padding(.vertical, 7)
117 + .background(on ? accent : KATheme.surface(scheme), in: Capsule())
118 + .foregroundStyle(on ? .white : .secondary)
119 + .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1))
120 + }
121 + .accessibilityAddTraits(on ? [.isSelected] : [])
122 + }
123 +
49 124 @ViewBuilder
50 125 private func collectionSection(_ c: FavoritesStore.FavCollection) -> some View {
51 − VStack(alignment: .leading, spacing: 10) {
52 − HStack {
53 − Label(c.name, systemImage: "folder.fill")
54 − .font(.headline)
55 − Spacer()
56 − Text("\(c.items.count)")
57 − .font(.system(.caption, design: .monospaced).weight(.bold))
58 − .foregroundStyle(.secondary)
59 − if favorites.collections.count > 1 {
60 − Menu {
61 − Button(role: .destructive) {
62 − favorites.removeCollection(c.id)
63 − } label: { Label("Supprimer la collection", systemImage: "trash") }
64 − } label: {
65 − Image(systemName: "ellipsis.circle").foregroundStyle(.secondary)
126 + let items = filtered(c.items)
127 + // une collection vidée par le filtre s'efface (sauf si aucune n'a rien)
128 + if !items.isEmpty || (filterUniverse == nil && searchText.isEmpty) {
129 + VStack(alignment: .leading, spacing: 10) {
130 + HStack {
131 + Label(c.name, systemImage: "folder.fill")
132 + .font(.headline)
133 + Spacer()
134 + Text("\(items.count)")
135 + .font(.system(.caption, design: .monospaced).weight(.bold))
136 + .foregroundStyle(.secondary)
137 + if favorites.collections.count > 1 {
138 + Menu {
139 + Button(role: .destructive) {
140 + favorites.removeCollection(c.id)
141 + } label: { Label("Supprimer la collection", systemImage: "trash") }
142 + } label: {
143 + Image(systemName: "ellipsis.circle").foregroundStyle(.secondary)
144 + }
66 145 }
67 146 }
68 − }
69 − if c.items.isEmpty {
70 − Text("Vide — ajoutez-y des trouvailles depuis n'importe quel univers.")
71 − .font(.caption).foregroundStyle(.tertiary)
72 − }
73 − ForEach(c.items) { item in
74 − NavigationLink(value: item) { KAItemRow(item: item) }
75 − .buttonStyle(.plain)
76 − .contextMenu {
77 − ForEach(favorites.collections.filter { $0.id != c.id }) { other in
78 − Button { favorites.move(item, to: other.id) } label: {
79 − Label("Déplacer vers \(other.name)", systemImage: "folder")
147 + if items.isEmpty {
148 + Text("Vide — ajoutez-y des trouvailles depuis n'importe quel univers.")
149 + .font(.caption).foregroundStyle(.tertiary)
150 + }
151 + ForEach(items) { item in
152 + NavigationLink(value: item) { KAItemRow(item: item) }
153 + .buttonStyle(.plain)
154 + .contextMenu {
155 + ForEach(favorites.collections.filter { $0.id != c.id }) { other in
156 + Button { favorites.move(item, to: other.id) } label: {
157 + Label("Déplacer vers \(other.name)", systemImage: "folder")
158 + }
159 + }
160 + if let url = item.url { ShareLink(item: url) { Label("Partager", systemImage: "square.and.arrow.up") } }
161 + Button(role: .destructive) { favorites.toggle(item) } label: {
162 + Label("Retirer", systemImage: "heart.slash")
80 163 }
81 164 }
82 − if let url = item.url { ShareLink(item: url) { Label("Partager", systemImage: "square.and.arrow.up") } }
83 − Button(role: .destructive) { favorites.toggle(item) } label: {
84 − Label("Retirer", systemImage: "heart.slash")
85 − }
86 − }
165 + }
87 166 }
88 167 }
89 168 }
added KA/Features/ForYouSection.swift +163 −0
@@ -0,0 +1,163 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// ForYouSection.swift — le feed « Pour vous » de l'accueil : un mélange
3 +// CLASSÉ (pas chronologique) des univers que l'utilisateur suit vraiment,
4 +// en grandes cartes immersives pleine largeur, chaque carte expliquant
5 +// honnêtement pourquoi elle est là. Apparaît seulement quand le profil a
6 +// assez de signal — jamais de fausse personnalisation à la première ouverture.
7 +import SwiftUI
8 +
9 +struct ForYouSection: View {
10 + let entries: [ForYouEntry]
11 + @Environment(\.colorScheme) private var scheme
12 +
13 + var body: some View {
14 + VStack(alignment: .leading, spacing: 12) {
15 + HStack(spacing: 8) {
16 + Image(systemName: "sparkles")
17 + .font(.subheadline.weight(.bold))
18 + .foregroundStyle(KATheme.lime)
19 + .frame(width: 28, height: 28)
20 + .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
21 + VStack(alignment: .leading, spacing: 0) {
22 + Text("Pour vous").font(KAFont.display(22))
23 + .foregroundStyle(KATheme.ink(scheme))
24 + Text("Selon vos visites — tout reste sur votre appareil")
25 + .font(.caption2).foregroundStyle(.secondary)
26 + }
27 + Spacer()
28 + }
29 + .accessibilityElement(children: .combine)
30 + .accessibilityAddTraits(.isHeader)
31 +
32 + ForEach(entries) { entry in
33 + NavigationLink(value: entry.item) {
34 + ForYouCard(entry: entry)
35 + }
36 + .buttonStyle(KAPressStyle())
37 + .kaScrollPop(axis: .vertical)
38 + }
39 + }
40 + }
41 +}
42 +
43 +/// Grande carte immersive : photo pleine largeur, raison en badge accent,
44 +/// titre display sur scrim. Sans photo : rangée éditoriale avec pastille.
45 +struct ForYouCard: View {
46 + let entry: ForYouEntry
47 + @Environment(\.colorScheme) private var scheme
48 +
49 + private var universe: Universe? { Ecosystem.universe(entry.item.universeID) }
50 + private var accent: Color { universe?.accent ?? KATheme.green }
51 +
52 + var body: some View {
53 + Group {
54 + if entry.item.imageURL != nil {
55 + immersive
56 + } else {
57 + row
58 + }
59 + }
60 + .accessibilityElement(children: .combine)
61 + .accessibilityLabel("\(entry.reason). \(entry.item.title)\(entry.item.priceLabel.map { ", \($0)" } ?? "")")
62 + }
63 +
64 + private var immersive: some View {
65 + ZStack(alignment: .bottomLeading) {
66 + // l'image vit en OVERLAY d'un cadre fixe : sa largeur idéale
67 + // (panoramas!) ne se propage jamais à la mise en page
68 + Color.clear
69 + .frame(height: 200)
70 + .frame(maxWidth: .infinity)
71 + .overlay(KAImage(url: entry.item.imageURL, accent: accent,
72 + symbol: universe?.symbol ?? "sparkles"))
73 + .clipped()
74 + LinearGradient(colors: [.clear, .black.opacity(0.10), .black.opacity(0.82)],
75 + startPoint: .top, endPoint: .bottom)
76 + VStack(alignment: .leading, spacing: 6) {
77 + HStack(spacing: 5) {
78 + Image(systemName: "sparkle").font(.system(size: 8, weight: .bold))
79 + Text(entry.reason.uppercased())
80 + .font(KAFont.mono(8))
81 + .kerning(0.8)
82 + .lineLimit(1)
83 + .minimumScaleFactor(0.8)
84 + }
85 + .padding(.horizontal, 9).padding(.vertical, 5)
86 + .background(accent, in: Capsule())
87 + .foregroundStyle(.white)
88 + Text(entry.item.title)
89 + .font(KAFont.display(19))
90 + .foregroundStyle(.white)
91 + .lineLimit(2)
92 + .multilineTextAlignment(.leading)
93 + HStack(spacing: 8) {
94 + if let p = entry.item.priceLabel {
95 + Text(p)
96 + .font(.system(.subheadline, design: .rounded, weight: .heavy))
97 + .foregroundStyle(KATheme.lime)
98 + }
99 + if let c = entry.item.city {
100 + Text(c).font(.caption).foregroundStyle(.white.opacity(0.78))
101 + }
102 + Spacer(minLength: 0)
103 + if let u = universe {
104 + Text(u.wordmark)
105 + .font(KAFont.mono(9))
106 + .foregroundStyle(.white)
107 + .padding(.horizontal, 8).padding(.vertical, 4)
108 + .background(.white.opacity(0.18), in: Capsule())
109 + }
110 + }
111 + }
112 + .padding(14)
113 + }
114 + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
115 + .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous)
116 + .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5))
117 + .background(RoundedRectangle(cornerRadius: 18, style: .continuous)
118 + .fill(accent.opacity(scheme == .dark ? 0.35 : 1))
119 + .offset(x: 6, y: 6))
120 + }
121 +
122 + private var row: some View {
123 + HStack(alignment: .top, spacing: 12) {
124 + RoundedRectangle(cornerRadius: 8, style: .continuous)
125 + .fill(accent)
126 + .frame(width: 5)
127 + .padding(.vertical, 2)
128 + VStack(alignment: .leading, spacing: 4) {
129 + HStack(spacing: 5) {
130 + Image(systemName: "sparkle").font(.system(size: 8, weight: .bold))
131 + Text(entry.reason.uppercased())
132 + .font(KAFont.mono(8))
133 + .kerning(0.6)
134 + .lineLimit(1)
135 + .minimumScaleFactor(0.85)
136 + }
137 + .foregroundStyle(accent)
138 + Text(entry.item.title)
139 + .font(.subheadline.weight(.bold))
140 + .foregroundStyle(KATheme.ink(scheme))
141 + .lineLimit(2)
142 + .multilineTextAlignment(.leading)
143 + HStack(spacing: 8) {
144 + if let p = entry.item.priceLabel {
145 + Text(p)
146 + .font(.system(.caption, design: .rounded).weight(.bold))
147 + .foregroundStyle(accent)
148 + .lineLimit(1)
149 + }
150 + if let c = entry.item.city {
151 + Text(c).font(.caption2).foregroundStyle(.tertiary).lineLimit(1)
152 + }
153 + Spacer(minLength: 0)
154 + if let u = universe {
155 + KAChip(text: u.wordmark, accent: u.accent)
156 + }
157 + }
158 + }
159 + }
160 + .padding(12)
161 + .kaCard(accent: accent)
162 + }
163 +}
modified KA/Features/HomeView.swift +44 −23
@@ -13,6 +13,8 @@ struct HomeView: View {
13 13 @EnvironmentObject private var favorites: FavoritesStore
14 14 @EnvironmentObject private var recents: RecentsStore
15 15 @State private var editorialItems: [String: [KAItem]] = [:]
16 + @StateObject private var perso = PersonalizationStore.shared
17 + @State private var forYou: [ForYouEntry] = []
16 18 @Environment(\.colorScheme) private var scheme
17 19
18 20 var body: some View {
@@ -20,6 +22,9 @@ struct HomeView: View {
20 22 ScrollView {
21 23 VStack(alignment: .leading, spacing: 24) {
22 24 heroCard
25 + if !forYou.isEmpty {
26 + ForYouSection(entries: forYou)
27 + }
23 28 launcher
24 29 pulseSection
25 30 resumeStrip
@@ -207,32 +212,35 @@ struct HomeView: View {
207 212 VStack(alignment: .leading, spacing: 10) {
208 213 UniverseSectionHeader(title: "Le pouls de l'écosystème", accent: KATheme.green,
209 214 detail: "EN DIRECT")
210 − ScrollView(.horizontal, showsIndicators: false) {
211 − HStack(spacing: 10) {
212 − ForEach(pulse, id: \.0.id) { (u, n) in
213 − NavigationLink(value: u.id) {
214 − HStack(spacing: 10) {
215 − KALogo(universe: u, size: 30)
216 − VStack(alignment: .leading, spacing: 1) {
217 − KACountUp(value: n, font: KAFont.display(17), color: u.accent)
218 − Text(u.unit.uppercased())
219 − .font(KAFont.mono(8))
220 − .foregroundStyle(.secondary)
221 − .lineLimit(1)
222 − }
215 + // mosaïque dashboard : les 6 plus gros compteurs, en tuiles franches
216 + LazyVGrid(columns: [GridItem(.flexible(), spacing: 12),
217 + GridItem(.flexible(), spacing: 12)], spacing: 12) {
218 + ForEach(pulse.prefix(6), id: \.0.id) { (u, n) in
219 + NavigationLink(value: u.id) {
220 + VStack(alignment: .leading, spacing: 6) {
221 + HStack {
222 + KALogo(universe: u, size: 26)
223 + Spacer()
224 + Circle().fill(u.accent).frame(width: 7, height: 7)
223 225 }
224 − .padding(.horizontal, 13).padding(.vertical, 10)
225 − .kaCard(accent: u.accent)
226 + KACountUp(value: n, font: KAFont.display(21), color: u.accent)
227 + Text(u.unit.uppercased())
228 + .font(KAFont.mono(8))
229 + .foregroundStyle(.secondary)
230 + .lineLimit(1)
231 + .minimumScaleFactor(0.8)
226 232 }
227 − .buttonStyle(.plain)
228 − .kaScrollPop()
229 − }
230 − if pulse.isEmpty {
231 − Text("Le pouls de l'écosystème…")
232 − .font(.caption).foregroundStyle(.tertiary).padding(10)
233 + .padding(12)
234 + .frame(maxWidth: .infinity, alignment: .leading)
235 + .kaCard(accent: u.accent)
233 236 }
237 + .buttonStyle(KAPressStyle())
238 + .kaScrollPop(axis: .vertical)
234 239 }
235 − .padding(.vertical, 4)
240 + }
241 + if pulse.isEmpty {
242 + Text("Le pouls de l'écosystème…")
243 + .font(.caption).foregroundStyle(.tertiary).padding(10)
236 244 }
237 245 }
238 246 .accessibilityLabel("Le pouls de l'écosystème en direct")
@@ -420,7 +428,7 @@ struct HomeView: View {
420 428
421 429 // MARK: données
422 430
423 − /// Univers mis en avant selon l'heure + les préférences d'onboarding.
431 + /// Univers mis en avant : affinité apprise > favoris d'onboarding > heure.
424 432 private var contextualUniverses: [Universe] {
425 433 let h = Calendar.current.component(.hour, from: Date())
426 434 let weekend = Calendar.current.isDateInWeekend(Date())
@@ -434,6 +442,11 @@ struct HomeView: View {
434 442 }
435 443 let favs = favUniversesRaw.split(separator: ",").map(String.init)
436 444 for f in favs.reversed() where !ids.contains(f) { ids.insert(f, at: 0) }
445 + if perso.enabled, perso.profile.hasSignals {
446 + for u in perso.profile.topUniverses(2).reversed() where !ids.prefix(4).contains(u) {
447 + ids.insert(u, at: 0)
448 + }
449 + }
437 450 return ids.prefix(4).compactMap(Ecosystem.universe).filter { $0.map != nil }
438 451 }
439 452
@@ -455,6 +468,14 @@ struct HomeView: View {
455 468 return items.isEmpty ? nil : (u, items)
456 469 }
457 470 loading = false
471 + // feed « Pour vous » — seulement avec un vrai profil (jamais de fausse perso)
472 + if perso.enabled, perso.profile.hasSignals {
473 + let seen = Set(recents.viewed.prefix(12).map(\.id))
474 + let feed = await RecommendationService.feed(profile: perso.profile, excluding: seen)
475 + withAnimation(.snappy) { forYou = feed }
476 + } else {
477 + forYou = []
478 + }
458 479 // pouls (compteurs live) — après le contenu pour ne pas retarder l'accueil
459 480 var counts: [(Universe, Int)] = []
460 481 await withTaskGroup(of: (String, Int?).self) { group in
modified KA/Features/MapView.swift +42 −2
@@ -57,7 +57,11 @@ struct UnifiedMapView: View {
57 57 center: .init(latitude: 46.81, longitude: -71.21),
58 58 span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3))
59 59 @State private var items: [KAItem] = []
60 − @State private var enabled: Set<String> = ["lou-ka", "immo-ka"]
60 + // couches actives, PERSISTÉES entre les sessions
61 + @AppStorage("ka.map.layers") private var layersRaw = "lou-ka,immo-ka"
62 + @State private var enabled: Set<String> = []
63 + @State private var showFavorites = false
64 + @EnvironmentObject private var favorites: FavoritesStore
61 65 @State private var loading = false
62 66 @State private var zoneDirty = false
63 67 @State private var selectedCluster: MapCluster?
@@ -77,9 +81,26 @@ struct UnifiedMapView: View {
77 81 private var vpZoomedEnough: Bool {
78 82 visibleRegion.span.latitudeDelta <= 0.16 && visibleRegion.span.longitudeDelta <= 0.24
79 83 }
80 − private var visibleItems: [KAItem] { items.filter { enabled.contains($0.universeID) } }
84 + /// Favoris géolocalisés dans la zone visible (couche « ♥ »)
85 + private var favoriteItems: [KAItem] {
86 + guard showFavorites else { return [] }
87 + let r = visibleRegion
88 + return favorites.allItems.filter { it in
89 + guard let la = it.latitude, let lo = it.longitude else { return false }
90 + return abs(la - r.center.latitude) <= r.span.latitudeDelta / 2
91 + && abs(lo - r.center.longitude) <= r.span.longitudeDelta / 2
92 + }
93 + }
94 + private var visibleItems: [KAItem] {
95 + var out = items.filter { enabled.contains($0.universeID) }
96 + let ids = Set(out.map(\.id))
97 + out += favoriteItems.filter { !ids.contains($0.id) }
98 + return out
99 + }
81 100 private var clusters: [MapCluster] { ClusterEngine.clusterize(visibleItems, region: visibleRegion) }
82 101
102 + private func persistLayers() { layersRaw = enabled.sorted().joined(separator: ",") }
103 +
83 104 var body: some View {
84 105 NavigationStack {
85 106 ZStack(alignment: .bottom) {
@@ -130,6 +151,8 @@ struct UnifiedMapView: View {
130 151 .presentationBackgroundInteraction(.enabled(upThrough: .medium))
131 152 }
132 153 .task {
154 + // restaure les couches choisies à la dernière session
155 + enabled = Set(layersRaw.split(separator: ",").map(String.init))
133 156 // pilotage debug : couche Vrai-Prix active + zoom quartier
134 157 // (defaults write com.groupeka.ka ka.debug.mapvp -bool true)
135 158 if UserDefaults.standard.bool(forKey: "ka.debug.mapvp") {
@@ -190,11 +213,28 @@ struct UnifiedMapView: View {
190 213 private var chips: some View {
191 214 ScrollView(.horizontal, showsIndicators: false) {
192 215 HStack(spacing: 8) {
216 + // couche transversale : vos favoris géolocalisés, tous univers
217 + if !favorites.allItems.isEmpty {
218 + Button {
219 + Haptics.tap()
220 + showFavorites.toggle()
221 + selectedCluster = nil
222 + } label: {
223 + Label("Favoris", systemImage: showFavorites ? "heart.fill" : "heart")
224 + .font(.system(.caption, design: .monospaced).weight(.bold))
225 + .padding(.horizontal, 11).padding(.vertical, 8)
226 + .background(showFavorites ? Color.red : KATheme.surface(scheme), in: Capsule())
227 + .foregroundStyle(showFavorites ? .white : .secondary)
228 + .overlay(Capsule().strokeBorder(.black.opacity(0.25), lineWidth: 1))
229 + }
230 + .accessibilityLabel("\(showFavorites ? "Masquer" : "Afficher") vos favoris sur la carte")
231 + }
193 232 ForEach(mapUniverses) { u in
194 233 let on = enabled.contains(u.id)
195 234 Button {
196 235 Haptics.tap()
197 236 if on { enabled.remove(u.id) } else { enabled.insert(u.id) }
237 + persistLayers()
198 238 selectedCluster = nil
199 239 Task { await loadZone() }
200 240 } label: {
added KA/Features/MortgageCard.swift +76 −0
@@ -0,0 +1,76 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// MortgageCard.swift — l'estimateur hypothécaire des fiches Immo·Ka : mise de
3 +// fonds ajustable, taux modifiable, paiement mensuel calculé LOCALEMENT et en
4 +// toute transparence (formule affichée en pied). Aucune donnée n'est envoyée.
5 +import SwiftUI
6 +
7 +struct MortgageCard: View {
8 + let price: Double
9 + let accent: Color
10 + @State private var downFraction: Double = 0.20
11 + @State private var rate: Double = 4.5
12 + @State private var years: Int = 25
13 + @Environment(\.colorScheme) private var scheme
14 +
15 + private var payment: Double {
16 + MortgageMath.monthlyPayment(price: price, downFraction: downFraction,
17 + annualRate: rate, years: years)
18 + }
19 + private var down: Double { price * downFraction }
20 +
21 + var body: some View {
22 + VStack(alignment: .leading, spacing: 12) {
23 + UniverseSectionHeader(title: "Paiement hypothécaire estimé", accent: accent)
24 +
25 + HStack(alignment: .firstTextBaseline, spacing: 6) {
26 + Text(payment.money0)
27 + .font(KAFont.display(28))
28 + .foregroundStyle(accent)
29 + .contentTransition(.numericText())
30 + Text("/ mois")
31 + .font(KAFont.mono(10))
32 + .foregroundStyle(.secondary)
33 + }
34 + .animation(.snappy, value: payment)
35 +
36 + VStack(alignment: .leading, spacing: 4) {
37 + HStack {
38 + Text("Mise de fonds")
39 + .font(.caption.weight(.semibold))
40 + Spacer()
41 + Text("\(Int(downFraction * 100)) % · \(down.money0)")
42 + .font(KAFont.mono(10))
43 + .foregroundStyle(accent)
44 + }
45 + Slider(value: $downFraction, in: 0.05...0.5, step: 0.05)
46 + .tint(accent)
47 + .accessibilityLabel("Mise de fonds")
48 + .accessibilityValue("\(Int(downFraction * 100)) pour cent")
49 + }
50 +
51 + HStack(spacing: 12) {
52 + Stepper(value: $rate, in: 1...10, step: 0.25) {
53 + VStack(alignment: .leading, spacing: 1) {
54 + Text("TAUX").font(KAFont.mono(8)).foregroundStyle(.secondary)
55 + Text(String(format: "%.2f %%", rate))
56 + .font(.subheadline.weight(.bold))
57 + }
58 + }
59 + .accessibilityLabel("Taux d'intérêt annuel")
60 + Stepper(value: $years, in: 5...30, step: 5) {
61 + VStack(alignment: .leading, spacing: 1) {
62 + Text("AMORTISSEMENT").font(KAFont.mono(8)).foregroundStyle(.secondary)
63 + Text("\(years) ans").font(.subheadline.weight(.bold))
64 + }
65 + }
66 + .accessibilityLabel("Période d'amortissement")
67 + }
68 +
69 + Text("Estimation indicative calculée sur votre appareil — taxes, assurance SCHL et frais exclus.")
70 + .font(.caption2).foregroundStyle(.tertiary)
71 + }
72 + .padding(14)
73 + .frame(maxWidth: .infinity, alignment: .leading)
74 + .kaCard(accent: accent)
75 + }
76 +}
modified KA/Features/ProfileView.swift +39 −0
@@ -10,7 +10,10 @@ struct ProfileView: View {
10 10 @StateObject private var kaid = KAIDManager.shared
11 11 @EnvironmentObject private var favorites: FavoritesStore
12 12 @EnvironmentObject private var recents: RecentsStore
13 + @StateObject private var perso = PersonalizationStore.shared
14 + @AppStorage(PersonalizationStore.enabledKey) private var persoEnabled = true
13 15 @State private var showHubSite = false
16 + @State private var confirmReset = false
14 17
15 18 private var favIDs: Set<String> {
16 19 Set(favUniversesRaw.split(separator: ",").map(String.init))
@@ -80,6 +83,33 @@ struct ProfileView: View {
80 83 }
81 84 }
82 85
86 + Section {
87 + Toggle(isOn: $persoEnabled) {
88 + Label("Recommandations personnalisées", systemImage: "sparkles")
89 + }
90 + if persoEnabled, perso.profile.hasSignals {
91 + let p = perso.profile
92 + if !p.affinity.isEmpty {
93 + LabeledContent("Univers suivis",
94 + value: p.topUniverses(3)
95 + .compactMap { Ecosystem.universe($0)?.name }
96 + .joined(separator: ", "))
97 + }
98 + if !p.topCities.isEmpty {
99 + LabeledContent("Villes", value: p.topCities.prefix(3).joined(separator: ", "))
100 + }
101 + LabeledContent("Signaux enregistrés", value: "\(p.eventCount)")
102 + }
103 + Button(role: .destructive) { confirmReset = true } label: {
104 + Label("Effacer ce que KA a appris", systemImage: "trash")
105 + }
106 + .disabled(perso.events.isEmpty)
107 + } header: {
108 + Text("Personnalisation")
109 + } footer: {
110 + Text("Vos consultations, favoris et recherches personnalisent l'accueil et le feed « Pour vous ». Tout reste sur cet appareil — rien n'est envoyé aux serveurs.")
111 + }
112 +
83 113 Section {
84 114 ForEach(Ecosystem.all) { u in
85 115 Button {
@@ -156,6 +186,15 @@ struct ProfileView: View {
156 186 SiteBrowserCover(title: "Groupe KA", accent: KATheme.green,
157 187 logoID: "groupe-ka", url: Ecosystem.hubURL)
158 188 }
189 + .confirmationDialog("Effacer les données d'apprentissage ?",
190 + isPresented: $confirmReset, titleVisibility: .visible) {
191 + Button("Tout effacer", role: .destructive) {
192 + perso.reset()
193 + Haptics.success()
194 + }
195 + } message: {
196 + Text("Le feed « Pour vous » repartira de zéro. Vos favoris et votre historique ne sont pas touchés.")
197 + }
159 198 }
160 199 }
161 200 }
modified KA/Features/SearchView.swift +83 −23
@@ -32,7 +32,8 @@ struct SearchView: View {
32 32 }
33 33 }
34 34 private func price(_ i: KAItem) -> Double {
35 − Double(i.priceLabel?.filter { $0.isNumber } ?? "") ?? (sort == .prixAsc ? .greatestFiniteMagnitude : 0)
35 + i.price ?? Double(i.priceLabel?.filter { $0.isNumber } ?? "")
36 + ?? (sort == .prixAsc ? .greatestFiniteMagnitude : 0)
36 37 }
37 38
38 39 private var history: [String] { historyRaw.split(separator: "\n").map(String.init) }
@@ -62,8 +63,18 @@ struct SearchView: View {
62 63 .padding(16)
63 64 }
64 65 .background(KATheme.paper(scheme))
65 − .navigationTitle("Recherche")
66 − .searchable(text: $query, prompt: "appartement Rouyn, resto italien, Corolla…")
66 + .navigationTitle("")
67 + .navigationBarTitleDisplayMode(.inline)
68 + .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always),
69 + prompt: "appartement Rouyn, resto italien, Corolla…")
70 + .searchSuggestions {
71 + // complétions pendant la frappe : l'historique d'abord
72 + if !query.isEmpty {
73 + ForEach(history.filter { $0.localizedCaseInsensitiveContains(query) && $0 != query }.prefix(4), id: \.self) { h in
74 + Label(h, systemImage: "clock.arrow.circlepath").searchCompletion(h)
75 + }
76 + }
77 + }
67 78 .onSubmit(of: .search) { Task { await search() } }
68 79 .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }
69 80 .toolbar {
@@ -122,35 +133,83 @@ struct SearchView: View {
122 133 }
123 134 }
124 135
136 + /// Idées de départ : chaque tuile porte l'ADN visuel de son univers.
137 + private static let ideas: [(query: String, universeID: String, symbol: String)] = [
138 + ("4½ à Québec", "lou-ka", "key.fill"),
139 + ("resto italien Montréal", "resto-ka", "fork.knife"),
140 + ("Toyota Corolla", "auto-ka", "car.fill"),
141 + ("emploi infirmière", "job-ka", "briefcase.fill"),
142 + ("spectacle ce week-end", "sorti-ka", "ticket.fill"),
143 + ("maison Gatineau", "immo-ka", "house.fill"),
144 + ]
145 +
125 146 private var suggestions: some View {
126 147 VStack(alignment: .leading, spacing: 14) {
148 + // manchette éditoriale
149 + VStack(alignment: .leading, spacing: 4) {
150 + HStack(spacing: 8) {
151 + Rectangle().fill(KATheme.lime).frame(width: 8, height: 8)
152 + Text("UNE BARRE · \(searchables.count) UNIVERS INTERROGÉS D'UN COUP")
153 + .font(KAFont.mono(9)).kerning(1.0)
154 + .foregroundStyle(KATheme.ink2(scheme))
155 + .lineLimit(1).minimumScaleFactor(0.8)
156 + }
157 + Text("Chercher dans\ntout le Québec")
158 + .font(KAFont.display(32))
159 + .foregroundStyle(KATheme.ink(scheme))
160 + }
127 161 if !history.isEmpty {
128 − Text("Récemment cherché").font(.headline)
129 − ForEach(history.prefix(6), id: \.self) { h in
130 − Button {
131 − query = h
132 − Task { await search(h) }
133 − } label: {
134 − HStack {
135 − Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary)
136 − Text(h)
137 − Spacer()
162 + UniverseSectionHeader(title: "Récemment cherché", accent: KATheme.green)
163 + KAFlow(spacing: 8) {
164 + ForEach(history.prefix(6), id: \.self) { h in
165 + Button {
166 + query = h
167 + Task { await search(h) }
168 + } label: {
169 + HStack(spacing: 6) {
170 + Image(systemName: "clock.arrow.circlepath")
171 + .font(.caption2).foregroundStyle(.secondary)
172 + Text(h).font(.caption.weight(.semibold)).lineLimit(1)
173 + }
174 + .padding(.horizontal, 12).padding(.vertical, 9)
175 + .background(KATheme.surface(scheme), in: Capsule())
176 + .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1))
177 + .foregroundStyle(KATheme.ink(scheme))
138 178 }
139 − .padding(12).kaCard()
179 + .buttonStyle(KAPressStyle())
140 180 }
141 − .buttonStyle(.plain)
142 181 }
143 182 }
144 − Text("Idées").font(.headline)
145 − ForEach(["4½ à Québec", "resto italien Montréal", "Toyota Corolla", "emploi infirmière", "spectacle ce week-end"], id: \.self) { s in
146 − Button { query = s; Task { await search(s) } } label: {
147 − HStack {
148 − Image(systemName: "sparkle.magnifyingglass").foregroundStyle(KATheme.green)
149 − Text(s); Spacer()
183 + UniverseSectionHeader(title: "Idées de départ", accent: KATheme.green)
184 + LazyVGrid(columns: [GridItem(.flexible(), spacing: 12),
185 + GridItem(.flexible(), spacing: 12)], spacing: 12) {
186 + ForEach(Self.ideas, id: \.query) { idea in
187 + let u = Ecosystem.universe(idea.universeID)
188 + Button { query = idea.query; Task { await search(idea.query) } } label: {
189 + VStack(alignment: .leading, spacing: 8) {
190 + Image(systemName: idea.symbol)
191 + .font(.headline)
192 + .foregroundStyle(.white)
193 + .frame(width: 34, height: 34)
194 + .background(u?.accent ?? KATheme.green, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
195 + Text(idea.query)
196 + .font(.subheadline.weight(.bold))
197 + .foregroundStyle(KATheme.ink(scheme))
198 + .lineLimit(2, reservesSpace: true)
199 + .multilineTextAlignment(.leading)
200 + if let u {
201 + Text(u.wordmark.uppercased())
202 + .font(KAFont.mono(8))
203 + .foregroundStyle(u.accent)
204 + }
205 + }
206 + .padding(13)
207 + .frame(maxWidth: .infinity, alignment: .topLeading)
208 + .kaCard(accent: u?.accent)
150 209 }
151 − .padding(12).kaCard()
210 + .buttonStyle(KAPressStyle())
211 + .kaScrollPop(axis: .vertical)
152 212 }
153 − .buttonStyle(.plain)
154 213 }
155 214 }
156 215 }
@@ -190,6 +249,7 @@ struct SearchView: View {
190 249 var hist = history.filter { $0 != q }
191 250 hist.insert(q, at: 0)
192 251 historyRaw = hist.prefix(10).joined(separator: "\n")
252 + PersonalizationStore.shared.recordSearch(q)
193 253
194 254 let targets = searchables.filter { selected.isEmpty || selected.contains($0.id) }
195 255 var found: [String: [KAItem]] = [:]
modified KA/Features/UniverseShowcase.swift +3 −1
@@ -145,9 +145,11 @@ struct UniverseShowcaseCard: View {
145 145
146 146 var body: some View {
147 147 ZStack(alignment: .bottomLeading) {
148 − KAImage(url: item.imageURL, accent: universe.accent, symbol: universe.symbol)
148 + // image en overlay d'un cadre fixe : jamais de débordement de layout
149 + Color.clear
149 150 .frame(height: 224)
150 151 .frame(maxWidth: .infinity)
152 + .overlay(KAImage(url: item.imageURL, accent: universe.accent, symbol: universe.symbol))
151 153 .clipped()
152 154 LinearGradient(colors: [.clear, .black.opacity(0.05), .black.opacity(0.78)],
153 155 startPoint: .top, endPoint: .bottom)
modified KA/Features/UniversesView.swift +327 −120
@@ -48,25 +48,47 @@ struct UniversesView: View {
48 48 @State private var totals: [String: Int] = [:]
49 49 @Environment(\.colorScheme) private var scheme
50 50
51 − private let columns = [GridItem(.adaptive(minimum: 160), spacing: 14)]
52 51 @State private var showMap = false
53 52 @State private var path = NavigationPath()
54 53
55 54 var body: some View {
56 55 NavigationStack(path: $path) {
57 56 ScrollView {
58 − LazyVGrid(columns: columns, spacing: 14) {
57 + VStack(alignment: .leading, spacing: 14) {
58 + // manchette éditoriale — l'index de l'écosystème
59 + VStack(alignment: .leading, spacing: 4) {
60 + HStack(spacing: 8) {
61 + Rectangle().fill(KATheme.lime).frame(width: 8, height: 8)
62 + Text("L'ÉCOSYSTÈME · \(Ecosystem.all.count) UNIVERS")
63 + .font(KAFont.mono(9.5)).kerning(1.1)
64 + .foregroundStyle(KATheme.ink2(scheme))
65 + }
66 + Text("Explorer")
67 + .font(KAFont.display(36))
68 + .foregroundStyle(KATheme.ink(scheme))
69 + Text("Mille sites. Un seul KA.")
70 + .font(.subheadline)
71 + .foregroundStyle(KATheme.ink2(scheme))
72 + }
73 + .padding(.bottom, 6)
59 74 ForEach(Ecosystem.all) { u in
60 75 NavigationLink(value: u.id) {
61 − UniverseCard(universe: u, total: totals[u.id])
76 + UniverseBand(universe: u, total: totals[u.id])
62 77 }
63 78 .buttonStyle(KAPressStyle())
79 + .kaScrollPop(axis: .vertical)
64 80 }
65 81 }
66 82 .padding(16)
67 83 }
68 − .background(KATheme.paper(scheme))
69 − .navigationTitle("Univers")
84 + .background {
85 + ZStack {
86 + KATheme.paper(scheme)
87 + KAAurora()
88 + }
89 + .ignoresSafeArea()
90 + }
91 + .navigationTitle("")
70 92 .toolbar {
71 93 ToolbarItem(placement: .topBarTrailing) {
72 94 Button { showMap = true } label: { Image(systemName: "map.fill") }
@@ -100,36 +122,69 @@ struct UniversesView: View {
100 122 }
101 123 }
102 124
103 −struct UniverseCard: View {
125 +/// Bande éditoriale d'un univers — l'index magazine de l'écosystème :
126 +/// gradient à l'accent, logo en filigrane, grand wordmark, compteur roulant.
127 +struct UniverseBand: View {
104 128 let universe: Universe
105 129 let total: Int?
106 130 @Environment(\.colorScheme) private var scheme
107 131
108 132 var body: some View {
109 − VStack(alignment: .leading, spacing: 10) {
110 − HStack {
111 − KALogo(universe: universe, size: 40)
112 − Spacer()
113 − Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary)
114 − }
115 − KAWordmark(universe: universe, size: 17)
116 − Text(universe.tagline)
117 − .font(.caption).foregroundStyle(KATheme.ink2(scheme))
118 − .lineLimit(2, reservesSpace: true)
119 − if let total {
120 − Text("\(total.formatted(.number.locale(Locale(identifier: "fr_CA")))) \(universe.unit)")
121 − .font(KAFont.mono(10))
122 − .foregroundStyle(universe.accent)
123 − .contentTransition(.numericText())
124 − } else {
125 − Text("—").font(KAFont.mono(10, bold: false))
126 − .foregroundStyle(.tertiary)
133 + ZStack(alignment: .topTrailing) {
134 + RoundedRectangle(cornerRadius: 18, style: .continuous)
135 + .fill(LinearGradient(
136 + colors: [universe.accent.opacity(scheme == .dark ? 0.30 : 0.20),
137 + universe.accent.opacity(scheme == .dark ? 0.08 : 0.03)],
138 + startPoint: .topTrailing, endPoint: .bottomLeading))
139 + .background(KATheme.surface(scheme),
140 + in: RoundedRectangle(cornerRadius: 18, style: .continuous))
141 + KALogo(universe: universe, size: 120)
142 + .opacity(scheme == .dark ? 0.14 : 0.10)
143 + .rotationEffect(.degrees(9))
144 + .offset(x: 26, y: -18)
145 + .accessibilityHidden(true)
146 + HStack(spacing: 12) {
147 + VStack(alignment: .leading, spacing: 5) {
148 + KAWordmark(universe: universe, size: 23)
149 + Text(universe.tagline)
150 + .font(.caption)
151 + .foregroundStyle(KATheme.ink2(scheme))
152 + .lineLimit(2)
153 + .multilineTextAlignment(.leading)
154 + HStack(spacing: 7) {
155 + Circle().fill(universe.accent).frame(width: 7, height: 7)
156 + if let total {
157 + KACountUp(value: total, font: KAFont.display(15),
158 + color: universe.accent)
159 + Text(universe.unit.uppercased())
160 + .font(KAFont.mono(8))
161 + .foregroundStyle(KATheme.ink2(scheme))
162 + .lineLimit(1)
163 + } else {
164 + Text("EN DIRECT")
165 + .font(KAFont.mono(8))
166 + .foregroundStyle(universe.accent)
167 + }
168 + }
169 + .padding(.top, 2)
170 + }
171 + Spacer(minLength: 0)
172 + Image(systemName: "arrow.right")
173 + .font(.subheadline.weight(.bold))
174 + .foregroundStyle(.white)
175 + .frame(width: 32, height: 32)
176 + .background(universe.accent, in: Circle())
127 177 }
178 + .padding(16)
128 179 }
129 − .padding(14)
130 − .frame(maxWidth: .infinity, alignment: .leading)
131 − .kaCard(accent: universe.accent)
180 + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
181 + .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous)
182 + .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.8), lineWidth: 1.5))
183 + .background(RoundedRectangle(cornerRadius: 18, style: .continuous)
184 + .fill(universe.accent.opacity(scheme == .dark ? 0.35 : 0.95))
185 + .offset(x: 6, y: 6))
132 186 .accessibilityElement(children: .combine)
187 + .accessibilityLabel("\(universe.name) — \(universe.tagline)")
133 188 }
134 189 }
135 190
@@ -294,6 +349,11 @@ struct UniverseHomeView: View {
294 349 Task { await load() }
295 350 }
296 351 .padding(.top, 12)
352 + // les chips MÉTIER de l'univers (4½, Sushi, Télétravail, ≤ 300 k$…)
353 + UniverseQuickChips(universe: universe, params: $filterParams) {
354 + Task { await load() }
355 + }
356 + .padding(.top, 8)
297 357 }
298 358 }
299 359
@@ -343,9 +403,50 @@ struct UniverseHomeView: View {
343 403
344 404 /// Mise en page MAGAZINE quand l'univers a des photos : vitrine pleine
345 405 /// largeur → carrousel « En vedette » → mosaïque 2 colonnes. Sans photos
346 − /// (Job·Ka, Trouve·Ka…) : liste éditoriale classique.
406 + /// (Job·Ka, Trouve·Ka…) : liste éditoriale classique. Sorti·Ka a son
407 + /// propre rendu : l'AGENDA (sections par horizon temporel).
347 408 @ViewBuilder
348 409 private var magazine: some View {
410 + if universe.id == "sorti-ka" {
411 + agenda
412 + } else {
413 + magazineBody
414 + }
415 + }
416 +
417 + /// L'agenda Sorti·Ka : les événements regroupés par quand ça se passe.
418 + @ViewBuilder
419 + private var agenda: some View {
420 + let groups = Dictionary(grouping: items) { EventBucket.bucket(iso: $0.date) }
421 + ForEach(EventBucket.allCases.sorted(), id: \.rawValue) { bucket in
422 + if let group = groups[bucket], !group.isEmpty {
423 + UniverseSectionHeader(title: bucket.label, accent: universe.accent,
424 + detail: "\(group.count)")
425 + let visuals = group.filter { $0.imageURL != nil }
426 + if visuals.count >= 2 {
427 + LazyVGrid(columns: [GridItem(.flexible(), spacing: 12),
428 + GridItem(.flexible(), spacing: 12)], spacing: 12) {
429 + ForEach(group) { it in
430 + NavigationLink(value: it) {
431 + UniverseMosaicCell(item: it, universe: universe)
432 + }
433 + .buttonStyle(KAPressStyle())
434 + .kaScrollPop(axis: .vertical)
435 + }
436 + }
437 + } else {
438 + ForEach(group) { it in
439 + NavigationLink(value: it) { KAItemRow(item: it) }
440 + .buttonStyle(KAPressStyle())
441 + .kaScrollPop(axis: .vertical)
442 + }
443 + }
444 + }
445 + }
446 + }
447 +
448 + @ViewBuilder
449 + private var magazineBody: some View {
349 450 let visuals = items.filter { $0.imageURL != nil }
350 451 if visuals.count >= 3, let showcase = visuals.first {
351 452 let featured = Array(visuals.dropFirst().prefix(6))
@@ -441,34 +542,178 @@ struct ItemDetailView: View {
441 542 /// L'item affiché : enrichi dès que l'API de détail répond, sinon la liste.
442 543 private var it: KAItem { enriched ?? item }
443 544 private var accent: Color { universe?.accent ?? KATheme.green }
545 + @Environment(\.openURL) private var openURL
546 +
547 + /// L'action principale parle la langue de l'univers — pas un générique.
548 + private var sourceCTA: (label: String, symbol: String) {
549 + switch it.universeID {
550 + case "job-ka": return ("Postuler chez l'employeur", "paperplane.fill")
551 + case "lou-ka": return ("Voir l'annonce du logement", "key.fill")
552 + case "immo-ka": return ("Voir chez le courtier", "house.fill")
553 + case "auto-ka": return ("Voir chez le concessionnaire", "car.fill")
554 + case "resto-ka": return ("Fiche du restaurant", "fork.knife")
555 + case "sorti-ka": return ("Billets & infos", "ticket.fill")
556 + case "food-ka": return ("Voir en épicerie", "cart.fill")
557 + case "fabri-ka": return ("Voir la boutique", "bag.fill")
558 + default: return ("Voir à la source", "arrow.up.right.square")
559 + }
560 + }
444 561
445 562 var body: some View {
446 563 ScrollView {
447 − VStack(alignment: .leading, spacing: 16) {
448 − if !it.imageURLs.isEmpty {
449 − TabView {
450 − ForEach(it.imageURLs, id: \.self) { img in
451 − KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")
452 − .onTapGesture { Haptics.tap(); fullScreenImage = img }
453 − .accessibilityLabel("Photo de \(it.title) — toucher pour agrandir")
454 − .accessibilityAddTraits(.isButton)
455 − }
456 − }
457 − .tabViewStyle(.page(indexDisplayMode: it.imageURLs.count > 1 ? .automatic : .never))
458 − .frame(height: 240)
459 − .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
460 − .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous)
461 − .strokeBorder(.primary.opacity(0.3), lineWidth: 1.2))
462 − .overlay(alignment: .topTrailing) {
463 − if it.imageURLs.count > 1 {
464 − Text("\(it.imageURLs.count) photos")
465 − .font(.system(size: 10, design: .monospaced).weight(.bold))
466 − .padding(.horizontal, 8).padding(.vertical, 4)
467 − .background(.ultraThinMaterial, in: Capsule())
468 − .padding(8)
469 − }
470 − }
564 + VStack(alignment: .leading, spacing: 0) {
565 + if !it.imageURLs.isEmpty { heroGallery }
566 + contentCard
567 + }
568 + }
569 + .ignoresSafeArea(edges: it.imageURLs.isEmpty ? [] : .top)
570 + .background(KATheme.paper(scheme))
571 + .navigationBarTitleDisplayMode(.inline)
572 + .toolbarBackground(it.imageURLs.isEmpty ? .automatic : .hidden, for: .navigationBar)
573 + .toolbar {
574 + ToolbarItem(placement: .topBarTrailing) {
575 + if let url = it.url {
576 + ShareLink(item: url, subject: Text(it.title))
471 577 }
578 + }
579 + }
580 + .safeAreaInset(edge: .bottom) {
581 + // barre d'action flottante — la safe area étendue du dock la pose
582 + // automatiquement au-dessus de lui, et le contenu défile net
583 + stickyBar
584 + .padding(.horizontal, 12)
585 + .padding(.bottom, 6)
586 + }
587 + .task {
588 + recents.record(item)
589 + PersonalizationStore.shared.record(.view, item: item)
590 + // fiche complète depuis l'API de détail du site (galerie entière,
591 + // description longue, inclusions, estimation Vrai-Prix, heures…)
592 + if let u = universe, enriched == nil,
593 + let full = await DetailService.enrich(item, universe: u) {
594 + withAnimation(.snappy) { enriched = full }
595 + }
596 + if it.universeID == "resto-ka" {
597 + let uid = String(it.id.dropFirst("resto-ka:".count))
598 + menu = await RestoMenuLoader.load(uid: uid)
599 + }
600 + if let u = universe, u.map != nil, similar.isEmpty {
601 + let batch = (try? await UniverseService.fetch(u, city: it.city, limit: 10)) ?? []
602 + similar = batch.filter { $0.id != it.id }.prefix(6).map { $0 }
603 + }
604 + }
605 + .fullScreenCover(item: $fullScreenImage) { img in
606 + ZoomableImageView(url: img, accent: universe?.accent ?? .gray)
607 + }
608 + .confirmationDialog("Ajouter à…", isPresented: $showCollections, titleVisibility: .visible) {
609 + ForEach(favorites.collections) { c in
610 + Button(c.name) { favorites.toggle(item, in: c.id) }
611 + }
612 + }
613 + .tint(universe?.accent)
614 + }
615 +
616 + // MARK: héros photo PLEIN CADRE (jusque sous la barre d'état)
617 +
618 + private var heroGallery: some View {
619 + TabView {
620 + ForEach(it.imageURLs, id: \.self) { img in
621 + KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")
622 + .onTapGesture { Haptics.tap(); fullScreenImage = img }
623 + .accessibilityLabel("Photo de \(it.title) — toucher pour agrandir")
624 + .accessibilityAddTraits(.isButton)
625 + }
626 + }
627 + .tabViewStyle(.page(indexDisplayMode: .never))
628 + .frame(height: 400)
629 + .clipped()
630 + // scrims : lisibilité du bouton retour en haut, fondu vers la carte en bas
631 + .overlay(alignment: .top) {
632 + LinearGradient(colors: [.black.opacity(0.38), .clear],
633 + startPoint: .top, endPoint: .bottom)
634 + .frame(height: 110)
635 + .allowsHitTesting(false)
636 + }
637 + .overlay(alignment: .bottomTrailing) {
638 + if it.imageURLs.count > 1 {
639 + Label("\(it.imageURLs.count) photos", systemImage: "photo.stack")
640 + .font(.system(size: 10, design: .monospaced).weight(.bold))
641 + .padding(.horizontal, 9).padding(.vertical, 5)
642 + .background(.ultraThinMaterial, in: Capsule())
643 + .padding(.trailing, 14)
644 + .padding(.bottom, 44)
645 + }
646 + }
647 + }
648 +
649 + // MARK: barre d'action collante — prix + favori + le geste de l'univers
650 +
651 + @ViewBuilder
652 + private var stickyBar: some View {
653 + HStack(spacing: 10) {
654 + VStack(alignment: .leading, spacing: 1) {
655 + if let p = it.priceLabel {
656 + Text(p)
657 + .font(KAFont.display(17))
658 + .foregroundStyle(accent)
659 + .lineLimit(1)
660 + .minimumScaleFactor(0.6)
661 + } else {
662 + Text(universe?.wordmark ?? "KA")
663 + .font(KAFont.display(15))
664 + .foregroundStyle(KATheme.ink(scheme))
665 + }
666 + if let c = it.city {
667 + Text(c).font(.caption2).foregroundStyle(.secondary).lineLimit(1)
668 + }
669 + }
670 + Spacer(minLength: 4)
671 + Button {
672 + if favorites.isFavorite(item) { favorites.toggle(item) }
673 + else if favorites.collections.count > 1 { showCollections = true }
674 + else { favorites.toggle(item) }
675 + } label: {
676 + Image(systemName: favorites.isFavorite(item) ? "heart.fill" : "heart")
677 + .font(.headline)
678 + .foregroundStyle(favorites.isFavorite(item) ? .red : KATheme.ink(scheme))
679 + .frame(width: 44, height: 44)
680 + .background(KATheme.surface(scheme), in: Circle())
681 + .overlay(Circle().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1.2))
682 + }
683 + .accessibilityLabel(favorites.isFavorite(item) ? "Retirer des favoris" : "Ajouter aux favoris")
684 + if let url = it.url {
685 + Button {
686 + Haptics.tap()
687 + PersonalizationStore.shared.record(.openSource, item: it)
688 + openURL(url)
689 + } label: {
690 + Label(sourceCTA.label, systemImage: sourceCTA.symbol)
691 + .font(.subheadline.weight(.bold))
692 + .lineLimit(1)
693 + .minimumScaleFactor(0.75)
694 + .padding(.horizontal, 16)
695 + .frame(height: 44)
696 + .background(accent, in: Capsule())
697 + .foregroundStyle(.white)
698 + }
699 + .buttonStyle(KAPressStyle())
700 + }
701 + }
702 + .padding(.horizontal, 12)
703 + .padding(.vertical, 8)
704 + .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
705 + .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous)
706 + .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.4 : 0.85), lineWidth: 1.5))
707 + .background(RoundedRectangle(cornerRadius: 18, style: .continuous)
708 + .fill(accent.opacity(scheme == .dark ? 0.4 : 0.95))
709 + .offset(x: 5, y: 5))
710 + .shadow(color: .black.opacity(0.15), radius: 12, y: 6)
711 + }
712 +
713 + // MARK: le contenu, en carte qui chevauche le héros
714 +
715 + private var contentCard: some View {
716 + VStack(alignment: .leading, spacing: 16) {
472 717 if let u = universe {
473 718 HStack {
474 719 KAChip(text: u.wordmark, accent: u.accent)
@@ -657,31 +902,25 @@ struct ItemDetailView: View {
657 902 }
658 903 }
659 904
660 − HStack(spacing: 10) {
661 − if let url = it.url {
662 − Link(destination: url) {
663 − Label("Voir à la source", systemImage: "arrow.up.right.square")
664 − .font(.headline)
665 − .frame(maxWidth: .infinity).padding(.vertical, 14)
666 − .background(universe?.accent ?? KATheme.green, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
667 − .foregroundStyle(.white)
668 − }
669 − }
670 − if let la = it.latitude, let lo = it.longitude {
671 − Button {
672 − Haptics.tap()
673 − let place = MKMapItem(placemark: MKPlacemark(coordinate: .init(latitude: la, longitude: lo)))
674 − place.name = it.title
675 − place.openInMaps(launchOptions: [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDefault])
676 − } label: {
677 − Label("Itinéraire", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
678 − .font(.headline)
679 − .padding(.horizontal, 16).padding(.vertical, 14)
680 − .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
681 − .foregroundStyle(KATheme.lime)
682 − }
683 − .accessibilityLabel("Itinéraire vers \(it.title)")
905 + // hypothèque estimée — l'ADN Immo·Ka (calcul local transparent)
906 + if it.universeID == "immo-ka", let price = it.price, price > 25_000 {
907 + MortgageCard(price: price, accent: accent)
908 + }
909 +
910 + if let la = it.latitude, let lo = it.longitude {
911 + Button {
912 + Haptics.tap()
913 + let place = MKMapItem(placemark: MKPlacemark(coordinate: .init(latitude: la, longitude: lo)))
914 + place.name = it.title
915 + place.openInMaps(launchOptions: [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDefault])
916 + } label: {
917 + Label("Itinéraire", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
918 + .font(.headline)
919 + .frame(maxWidth: .infinity).padding(.vertical, 13)
920 + .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
921 + .foregroundStyle(KATheme.lime)
684 922 }
923 + .accessibilityLabel("Itinéraire vers \(it.title)")
685 924 }
686 925 if let src = it.url?.host() {
687 926 Text("Source : \(src) — Groupe KA est un agrégateur, la transaction se fait chez la source originale.")
@@ -718,52 +957,20 @@ struct ItemDetailView: View {
718 957 }
719 958 }
720 959 }
721 − }
722 − .padding(16)
723 960 }
724 − .background(KATheme.paper(scheme))
725 − .navigationBarTitleDisplayMode(.inline)
726 − .toolbar {
727 − ToolbarItemGroup(placement: .topBarTrailing) {
728 − Button {
729 − if favorites.isFavorite(item) { favorites.toggle(item) }
730 − else if favorites.collections.count > 1 { showCollections = true }
731 − else { favorites.toggle(item) }
732 − } label: {
733 − Image(systemName: favorites.isFavorite(item) ? "heart.fill" : "heart")
734 − .foregroundStyle(favorites.isFavorite(item) ? .red : .primary)
735 − }
736 − .accessibilityLabel(favorites.isFavorite(item) ? "Retirer des favoris" : "Ajouter aux favoris")
737 − if let url = it.url {
738 − ShareLink(item: url, subject: Text(it.title))
739 − }
740 − }
741 − }
742 − .task {
743 − recents.record(item)
744 − // fiche complète depuis l'API de détail du site (galerie entière,
745 − // description longue, inclusions, estimation Vrai-Prix, heures…)
746 − if let u = universe, enriched == nil,
747 − let full = await DetailService.enrich(item, universe: u) {
748 − withAnimation(.snappy) { enriched = full }
749 − }
750 − if it.universeID == "resto-ka" {
751 − let uid = String(it.id.dropFirst("resto-ka:".count))
752 − menu = await RestoMenuLoader.load(uid: uid)
753 − }
754 − if let u = universe, u.map != nil, similar.isEmpty {
755 − let batch = (try? await UniverseService.fetch(u, city: it.city, limit: 10)) ?? []
756 − similar = batch.filter { $0.id != it.id }.prefix(6).map { $0 }
757 − }
758 − }
759 − .fullScreenCover(item: $fullScreenImage) { img in
760 − ZoomableImageView(url: img, accent: universe?.accent ?? .gray)
761 − }
762 − .confirmationDialog("Ajouter à…", isPresented: $showCollections, titleVisibility: .visible) {
763 − ForEach(favorites.collections) { c in
764 − Button(c.name) { favorites.toggle(item, in: c.id) }
961 + .padding(16)
962 + .padding(.top, it.imageURLs.isEmpty ? 0 : 6)
963 + .frame(maxWidth: .infinity, alignment: .leading)
964 + .background(KATheme.paper(scheme),
965 + in: UnevenRoundedRectangle(topLeadingRadius: 26, topTrailingRadius: 26,
966 + style: .continuous))
967 + .overlay(alignment: .top) {
968 + // poignée discrète, façon feuille
969 + if !it.imageURLs.isEmpty {
970 + Capsule().fill(.tertiary).frame(width: 38, height: 4).padding(.top, 8)
765 971 }
766 972 }
767 − .tint(universe?.accent)
973 + .offset(y: it.imageURLs.isEmpty ? 0 : -26)
974 + .padding(.bottom, it.imageURLs.isEmpty ? 0 : -26)
768 975 }
769 976 }
modified KATests/KATests.swift +130 −0
@@ -155,6 +155,136 @@ final class RefonteTests: XCTestCase {
155 155 }
156 156 }
157 157
158 +// MARK: - v3.1.0 : personnalisation, agenda Sorti·Ka, hypothèque, chips métier
159 +
160 +final class PersonalizationTests: XCTestCase {
161 + private func event(_ kind: InteractionKind, u: String? = "lou-ka", city: String? = nil,
162 + price: Double? = nil, tags: [String] = [], query: String? = nil,
163 + daysAgo: Double = 0) -> InteractionEvent {
164 + InteractionEvent(kind: kind, universeID: u, city: city, price: price,
165 + tags: tags, query: query,
166 + date: Date().addingTimeInterval(-daysAgo * 86_400))
167 + }
168 +
169 + func testAffinityFavoriteBeatsView() {
170 + let events = [event(.view, u: "lou-ka"), event(.favorite, u: "resto-ka")]
171 + let p = PreferenceEngine.computeProfile(events)
172 + XCTAssertEqual(p.topUniverses(1).first, "resto-ka", "un favori pèse plus qu'une vue")
173 + XCTAssertEqual(p.affinity["resto-ka"] ?? 0, 1.0, accuracy: 0.001, "affinité normalisée à 1")
174 + }
175 +
176 + func testDecayOldSignalsFade() {
177 + let events = [event(.view, u: "auto-ka", daysAgo: 60), event(.view, u: "lou-ka", daysAgo: 0)]
178 + let p = PreferenceEngine.computeProfile(events)
179 + XCTAssertEqual(p.topUniverses(1).first, "lou-ka", "le signal frais domine l'ancien")
180 + }
181 +
182 + func testUnfavoriteIsNegative() {
183 + let events = [event(.favorite, u: "job-ka"), event(.unfavorite, u: "job-ka"),
184 + event(.view, u: "lou-ka")]
185 + let p = PreferenceEngine.computeProfile(events)
186 + XCTAssertEqual(p.topUniverses(1).first, "lou-ka", "le retrait annule le favori")
187 + }
188 +
189 + func testHasSignalsThreshold() {
190 + let few = PreferenceEngine.computeProfile([event(.view)])
191 + XCTAssertFalse(few.hasSignals, "pas de fausse personnalisation avec 1 geste")
192 + let enough = PreferenceEngine.computeProfile((0..<6).map { _ in event(.view, city: "Québec") })
193 + XCTAssertTrue(enough.hasSignals)
194 + XCTAssertEqual(enough.topCities.first, "Québec")
195 + }
196 +
197 + func testScorePrefersCityAndPriceBand() {
198 + let events = (0..<6).map { _ in event(.view, u: "lou-ka", city: "Québec", price: 1200) }
199 + let p = PreferenceEngine.computeProfile(events)
200 + func it(_ id: String, city: String?, price: Double?) -> KAItem {
201 + KAItem(id: id, universeID: "lou-ka", title: "t", subtitle: nil, priceLabel: nil,
202 + city: city, url: nil, imageURL: nil, latitude: nil, longitude: nil,
203 + detail: nil, facts: [], price: price)
204 + }
205 + let match = PreferenceEngine.score(it("a", city: "Québec", price: 1250), profile: p)
206 + let off = PreferenceEngine.score(it("b", city: "Gatineau", price: 3900), profile: p)
207 + XCTAssertGreaterThan(match, off, "ville + gamme de prix doivent booster le score")
208 + }
209 +
210 + func testQueriesFeedTags() {
211 + let events = (0..<5).map { _ in event(.search, u: nil, query: "condo vieux montréal") }
212 + + [event(.view, u: "immo-ka")]
213 + let p = PreferenceEngine.computeProfile(events)
214 + XCTAssertTrue(p.topTags.contains("condo"), "les mots de recherche nourrissent les tags")
215 + }
216 +}
217 +
218 +final class EventBucketTests: XCTestCase {
219 + private let fmt: DateFormatter = {
220 + let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
221 + }()
222 + private func iso(_ daysFromNow: Int) -> String {
223 + fmt.string(from: Calendar.current.date(byAdding: .day, value: daysFromNow, to: .now)!)
224 + }
225 +
226 + func testBuckets() {
227 + XCTAssertEqual(EventBucket.bucket(iso: iso(0)), .today)
228 + XCTAssertEqual(EventBucket.bucket(iso: iso(3)), .thisWeek)
229 + XCTAssertEqual(EventBucket.bucket(iso: iso(20)), .thisMonth)
230 + XCTAssertEqual(EventBucket.bucket(iso: iso(90)), .later)
231 + XCTAssertEqual(EventBucket.bucket(iso: iso(-10)), .ongoing, "commencé mais actif (upcoming)")
232 + XCTAssertEqual(EventBucket.bucket(iso: nil), .undated)
233 + XCTAssertEqual(EventBucket.bucket(iso: "n'importe quoi"), .undated)
234 + }
235 +
236 + func testBucketOrderIsChronological() {
237 + XCTAssertLessThan(EventBucket.today, EventBucket.thisWeek)
238 + XCTAssertLessThan(EventBucket.thisWeek, EventBucket.thisMonth)
239 + XCTAssertLessThan(EventBucket.thisMonth, EventBucket.later)
240 + }
241 +}
242 +
243 +final class MortgageTests: XCTestCase {
244 + func testMonthlyPaymentKnownValue() {
245 + // 400 000 $, 20 % de mise de fonds, 5 %/an, 25 ans → ≈ 1 870,68 $/mois
246 + let m = MortgageMath.monthlyPayment(price: 400_000, downFraction: 0.20,
247 + annualRate: 5, years: 25)
248 + XCTAssertEqual(m, 1870.68, accuracy: 1.0)
249 + }
250 +
251 + func testZeroRateIsLinear() {
252 + let m = MortgageMath.monthlyPayment(price: 120_000, downFraction: 0,
253 + annualRate: 0, years: 10)
254 + XCTAssertEqual(m, 1000, accuracy: 0.01)
255 + }
256 +}
257 +
258 +final class QuickFilterTests: XCTestCase {
259 + func testChipsExistForCoreUniverses() {
260 + for id in ["lou-ka", "immo-ka", "auto-ka", "resto-ka", "sorti-ka", "job-ka"] {
261 + XCTAssertFalse(QuickFilterCatalog.chips(for: id).isEmpty, "chips manquantes : \(id)")
262 + }
263 + }
264 +
265 + func testRestoCuisineSlugsAreRealAPIValues() {
266 + // slugs vérifiés live le 2026-08-26 (cuisine=sushi retournait 0)
267 + let valid: Set<String> = ["autre", "bbq-grillades", "burgers", "cafe-dessert",
268 + "chinois", "dejeuner-brunch", "fruits-de-mer", "indien",
269 + "italien", "mexicain", "pizza", "poulet", "quebecois",
270 + "sushi-japonais", "vegetarien-vegan"]
271 + for chip in QuickFilterCatalog.chips(for: "resto-ka") {
272 + if let c = chip.params["cuisine"] {
273 + XCTAssertTrue(valid.contains(c), "slug de cuisine inconnu de l'API : \(c)")
274 + }
275 + }
276 + }
277 +
278 + func testOldFavoritesJSONStillDecodes() {
279 + // un favori sérialisé AVANT l'ajout de price/date doit rester lisible
280 + let old = #"{"id":"lou-ka:1","universeID":"lou-ka","title":"4½","imageURLs":[],"facts":[],"links":[]}"#
281 + let item = try? JSONDecoder().decode(KAItem.self, from: Data(old.utf8))
282 + XCTAssertNotNil(item)
283 + XCTAssertNil(item?.price)
284 + XCTAssertNil(item?.date)
285 + }
286 +}
287 +
158 288 // MARK: - v2.0.0 : logos officiels + sites intégrés
159 289
160 290 final class SitesLogosTests: XCTestCase {
modified project.yml +2 −2
@@ -9,8 +9,8 @@ options:
9 9 settings:
10 10 base:
11 11 SWIFT_VERSION: "5.0"
12 − MARKETING_VERSION: "3.0.0"
13 − CURRENT_PROJECT_VERSION: "13"
12 + MARKETING_VERSION: "3.1.0"
13 + CURRENT_PROJECT_VERSION: "14"
14 14 DEVELOPMENT_TEAM: "3YM54G49SN"
15 15 CODE_SIGN_STYLE: Automatic
16 16 GENERATE_INFOPLIST_FILE: true
17 17