diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..576a6e7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,73 @@ +# agents.md + +This monorepo contains two cooperating apps that together provide a “Google Now on Glass” experience. The system is deliberately split so that heavy computation and data access live on the phone, while Glass remains a lightweight, glance-first client. + +--- + +## IrisCompanion (iOS) + +**Role:** Context engine and system brain. + +**Responsibilities:** +- Collect user context (location, time, calendar, environment). +- Generate a small, ranked set of “Now” cards. +- Decide what matters *right now* and suppress noise. +- Act as the source of truth for card content and ordering. +- Deliver updates to Glass over a low-power Bluetooth channel. + +**Design principles:** +- Phone does the thinking; Glass does the displaying. +- Strong throttling and expiry to avoid notification fatigue. +- Cards are ephemeral and contextual, not a permanent feed. +- Battery-conscious: push updates only when context changes. + +**What it is not:** +- Not a UI-heavy app. +- Not a timeline renderer. +- Not dependent on Glass-specific UI concepts. + +--- + +## IrisGlass (Google Glass Explorer) + +**Role:** Glanceable display and interaction surface. + +**Responsibilities:** +- Maintain a lightweight connection to IrisCompanion. +- Receive and cache the current “Now” state. +- Present the most relevant card as a persistent Glass surface. +- Recover automatically from disconnects. +- Optionally expose limited interaction (dismiss, snooze). + +**Design principles:** +- One primary surface, always available. +- Fast to glance, minimal user effort. +- No local decision-making about importance. +- Prefer replacement over accumulation of cards. + +**What it is not:** +- Not a launcher replacement. +- Not a full feed manager. +- Not a context engine. + +--- + +## System Philosophy + +- **Single source of truth:** IrisCompanion decides; IrisGlass renders. +- **Low noise:** Fewer cards, higher confidence. +- **Glass-native:** Respect the timeline and LiveCard patterns. +- **Battery first:** Bluetooth over Wi-Fi; minimal background work. +- **Ephemeral by default:** Cards expire and disappear naturally. + +--- + +## Evolution Notes + +- Early development may use fixture data and diagnostic UIs. +- Additional card types can be added incrementally. +- More advanced ranking or summarisation may be introduced later. +- Timeline publishing should remain limited and intentional. + +End of file. + diff --git a/IrisCompanion/iris.xcodeproj/project.pbxproj b/IrisCompanion/iris.xcodeproj/project.pbxproj index c789cd3..2ed6c31 100644 --- a/IrisCompanion/iris.xcodeproj/project.pbxproj +++ b/IrisCompanion/iris.xcodeproj/project.pbxproj @@ -415,7 +415,11 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iris/Info.plist; + INFOPLIST_KEY_NSAppleMusicUsageDescription = "Allow access to your Now Playing information to show music on Glass."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Allow Bluetooth to send context updates to Glass."; + INFOPLIST_KEY_NSCalendarsUsageDescription = "Allow calendar access to show upcoming events on Glass."; + INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "Allow background location access to keep Glass context updated while moving."; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Allow location access to compute context cards for Glass."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -446,7 +450,11 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iris/Info.plist; + INFOPLIST_KEY_NSAppleMusicUsageDescription = "Allow access to your Now Playing information to show music on Glass."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Allow Bluetooth to send context updates to Glass."; + INFOPLIST_KEY_NSCalendarsUsageDescription = "Allow calendar access to show upcoming events on Glass."; + INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "Allow background location access to keep Glass context updated while moving."; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Allow location access to compute context cards for Glass."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/IrisCompanion/iris/DataSources/POIDataSource.swift b/IrisCompanion/iris/DataSources/POIDataSource.swift index f9bc813..8d9e6ba 100644 --- a/IrisCompanion/iris/DataSources/POIDataSource.swift +++ b/IrisCompanion/iris/DataSources/POIDataSource.swift @@ -7,17 +7,46 @@ import CoreLocation import Foundation +import MapKit struct POIDataSourceConfig: Sendable { - var maxCandidates: Int = 3 + var maxCandidates: Int = 2 + var searchRadiusMeters: CLLocationDistance = 600 + var transitBoostRadiusMeters: CLLocationDistance = 200 + var walkingSpeedMps: Double = 1.4 + var minTTLSeconds: Int = 60 + var maxTTLSeconds: Int = 20 * 60 init() {} } -/// Placeholder POI source. Hook point for MapKit / local cache / server-driven POIs. +/// MapKit-backed POI source. final class POIDataSource { + enum POIType: String, Codable, CaseIterable, Sendable { + case transit = "TRANSIT" + case cafe = "CAFE" + case food = "FOOD" + case park = "PARK" + case shopping = "SHOPPING" + case grocery = "GROCERY" + case fitness = "FITNESS" + case entertainment = "ENTERTAINMENT" + case health = "HEALTH" + case lodging = "LODGING" + case education = "EDUCATION" + case services = "SERVICES" + case other = "OTHER" + } + struct POI: Sendable, Equatable { let id: String let name: String + let category: String + let poiType: POIType + let distanceMeters: CLLocationDistance + let walkingMinutes: Int + let ttlSec: Int + let confidence: Double + let isTransit: Bool } private let config: POIDataSourceConfig @@ -27,11 +56,216 @@ final class POIDataSource { } func data(for location: CLLocation, now: Int) async throws -> [POI] { - // Phase 1 stub: return nothing. - // (Still async/throws so the orchestrator can treat it uniformly with real implementations later.) - _ = config - _ = location + print("finding pois for location \(location)") + _ = now - return [] + + let request = MKLocalPointsOfInterestRequest( + center: location.coordinate, + radius: config.searchRadiusMeters + ) + request.pointOfInterestFilter = .includingAll + + let items = try await search(request: request) + + print(items) + + var results: [POI] = [] + results.reserveCapacity(min(items.count, config.maxCandidates)) + var seen: Set = [] + + for item in items { + let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { continue } + + let coordinate = item.placemark.coordinate + let itemLocation = item.placemark.location ?? CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) + let distance = location.distance(from: itemLocation) + guard distance <= config.searchRadiusMeters else { continue } + + let mkCategory = item.pointOfInterestCategory + let categoryRaw = mkCategory?.rawValue + let category = categoryLabel(from: categoryRaw) + let poiType = poiType(for: mkCategory, categoryRaw: categoryRaw, name: name) + let isTransit = poiType == .transit + let walkingMinutes = walkingMinutes(for: distance) + let ttlSec = ttlSeconds(radius: config.searchRadiusMeters, speed: location.speed) + let confidence = confidenceScore(distance: distance, isTransit: isTransit) + + let id = stableId(name: name, coordinate: coordinate, category: poiType.rawValue) + guard !seen.contains(id) else { continue } + seen.insert(id) + + results.append( + POI( + id: id, + name: name, + category: category, + poiType: poiType, + distanceMeters: distance, + walkingMinutes: walkingMinutes, + ttlSec: ttlSec, + confidence: confidence, + isTransit: isTransit + ) + ) + } + + results.sort { + if $0.confidence == $1.confidence { + return $0.distanceMeters < $1.distanceMeters + } + return $0.confidence > $1.confidence + } + + if results.count > config.maxCandidates { + return Array(results.prefix(config.maxCandidates)) + } + return results } + + private func search(request: MKLocalPointsOfInterestRequest) async throws -> [MKMapItem] { + try await withCheckedThrowingContinuation { continuation in + let search = MKLocalSearch(request: request) + search.start { response, error in + if let error { + continuation.resume(throwing: error) + return + } + continuation.resume(returning: response?.mapItems ?? []) + } + } + } + + private func stableId(name: String, coordinate: CLLocationCoordinate2D, category: String) -> String { + let lat = String(format: "%.5f", coordinate.latitude) + let lon = String(format: "%.5f", coordinate.longitude) + let rawCategory = category.lowercased() + let raw = "poi:\(rawCategory):\(name):\(lat):\(lon)" + return raw + .replacingOccurrences(of: " ", with: "_") + .replacingOccurrences(of: "\n", with: "_") + .replacingOccurrences(of: "\t", with: "_") + } + + private func walkingMinutes(for distanceMeters: CLLocationDistance) -> Int { + let speed = max(config.walkingSpeedMps, 0.5) + let seconds = distanceMeters / speed + let minutes = Int(ceil(seconds / 60.0)) + return max(1, minutes) + } + + private func ttlSeconds(radius: CLLocationDistance, speed: CLLocationSpeed) -> Int { + let safeSpeed = max(speed, 0.5) + let secondsToExit = radius / safeSpeed + let ttl = Int(secondsToExit.rounded()) + return min(max(ttl, config.minTTLSeconds), config.maxTTLSeconds) + } + + private func confidenceScore(distance: CLLocationDistance, isTransit: Bool) -> Double { + let normalized = max(0.0, min(1.0, 1.0 - (distance / max(config.searchRadiusMeters, 1)))) + let base = isTransit ? 0.55 : 0.25 + let range = isTransit ? 0.25 : 0.2 + let transitBoost = (isTransit && distance <= config.transitBoostRadiusMeters) ? 0.1 : 0.0 + return min(max(base + (range * normalized) + transitBoost, 0.0), 0.9) + } + + private func categoryLabel(from raw: String?) -> String { + guard var raw = raw else { return "Place" } + if raw.hasPrefix("MKPOICategory") { + raw = String(raw.dropFirst("MKPOICategory".count)) + } + raw = raw.replacingOccurrences(of: "_", with: " ").replacingOccurrences(of: "-", with: " ") + if raw.isEmpty { return "Place" } + + var result = "" + for scalar in raw.unicodeScalars { + if CharacterSet.uppercaseLetters.contains(scalar), + let last = result.unicodeScalars.last, + !CharacterSet.whitespaces.contains(last) { + result.append(" ") + } + result.append(String(scalar)) + } + let trimmed = result.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "Place" : trimmed + } + + private func isTransitCategory(categoryRaw: String?, name: String) -> Bool { + let haystack = ([categoryRaw, name].compactMap { $0 }.joined(separator: " ")).lowercased() + let tokens = ["bus", "train", "rail", "subway", "metro", "tram", "ferry", "transit", "transport"] + return tokens.contains { haystack.contains($0) } + } + + private func poiType(for category: MKPointOfInterestCategory?, categoryRaw: String?, name: String) -> POIType { + if let category { + switch category { + case .publicTransport, .airport: + return .transit + case .cafe: + return .cafe + case .restaurant, .bakery, .brewery, .winery: + return .food + case .foodMarket: + return .grocery + case .park, .nationalPark, .beach, .campground, .marina: + return .park + case .fitnessCenter: + return .fitness + case .hotel: + return .lodging + case .school, .university, .library: + return .education + case .hospital, .pharmacy: + return .health + case .store: + return .shopping + case .movieTheater, .museum, .nightlife, .amusementPark, .aquarium, .stadium, .theater, .zoo: + return .entertainment + case .atm, .bank, .postOffice, .police, .fireStation, .parking, .laundry, .carRental, .evCharger, .restroom: + return .services + default: + break + } + } + + if isTransitCategory(categoryRaw: categoryRaw, name: name) { + return .transit + } + + let lower = name.lowercased() + if lower.contains("cafe") || lower.contains("coffee") { + return .cafe + } + if lower.contains("restaurant") || lower.contains("pizza") || lower.contains("food") { + return .food + } + if lower.contains("market") || lower.contains("grocery") { + return .grocery + } + if lower.contains("park") { + return .park + } + if lower.contains("gym") || lower.contains("fitness") { + return .fitness + } + if lower.contains("museum") || lower.contains("theater") || lower.contains("cinema") || lower.contains("zoo") { + return .entertainment + } + if lower.contains("school") || lower.contains("university") || lower.contains("college") { + return .education + } + if lower.contains("hotel") { + return .lodging + } + if lower.contains("hospital") || lower.contains("pharmacy") { + return .health + } + if lower.contains("mall") || lower.contains("store") || lower.contains("shop") { + return .shopping + } + + return .other + } + } diff --git a/IrisCompanion/iris/Info.plist b/IrisCompanion/iris/Info.plist index 7a0ae51..477e4e9 100644 --- a/IrisCompanion/iris/Info.plist +++ b/IrisCompanion/iris/Info.plist @@ -2,18 +2,10 @@ - NSBluetoothAlwaysUsageDescription - Allow Bluetooth to send context updates to Glass. - NSCalendarsUsageDescription - Allow calendar access to show upcoming events on Glass. + MKDirectionsApplicationSupportedModes + NSCalendarsFullAccessUsageDescription Allow full calendar access to show upcoming events on Glass. - NSAppleMusicUsageDescription - Allow access to your Now Playing information to show music on Glass. - NSLocationWhenInUseUsageDescription - Allow location access to compute context cards for Glass. - NSLocationAlwaysAndWhenInUseUsageDescription - Allow background location access to keep Glass context updated while moving. UIBackgroundModes bluetooth-peripheral diff --git a/IrisCompanion/iris/Models/FeedEnvelope.swift b/IrisCompanion/iris/Models/FeedEnvelope.swift index 2fd93e0..77d1aa0 100644 --- a/IrisCompanion/iris/Models/FeedEnvelope.swift +++ b/IrisCompanion/iris/Models/FeedEnvelope.swift @@ -36,6 +36,7 @@ struct FeedItem: Codable, Equatable { let ttlSec: Int let condition: WeatherKit.WeatherCondition? let startsAt: Int? + let poiType: POIDataSource.POIType? let bucket: Bucket let actions: [String] @@ -48,6 +49,7 @@ struct FeedItem: Codable, Equatable { case ttlSec = "ttl_sec" case condition case startsAt = "starts_at" + case poiType = "poi_type" case bucket case actions } @@ -60,6 +62,7 @@ struct FeedItem: Codable, Equatable { ttlSec: Int, condition: WeatherKit.WeatherCondition? = nil, startsAt: Int? = nil, + poiType: POIDataSource.POIType? = nil, bucket: Bucket, actions: [String]) { self.id = id @@ -70,6 +73,7 @@ struct FeedItem: Codable, Equatable { self.ttlSec = ttlSec self.condition = condition self.startsAt = startsAt + self.poiType = poiType self.bucket = bucket self.actions = actions } @@ -85,6 +89,11 @@ struct FeedItem: Codable, Equatable { bucket = try container.decode(Bucket.self, forKey: .bucket) actions = try container.decode([String].self, forKey: .actions) startsAt = try container.decodeIfPresent(Int.self, forKey: .startsAt) + if let raw = try container.decodeIfPresent(String.self, forKey: .poiType) { + poiType = POIDataSource.POIType(rawValue: raw) ?? .other + } else { + poiType = nil + } if let encoded = try container.decodeIfPresent(String.self, forKey: .condition) { condition = WeatherKit.WeatherCondition.irisDecode(encoded) @@ -104,6 +113,9 @@ struct FeedItem: Codable, Equatable { try container.encode(bucket, forKey: .bucket) try container.encode(actions, forKey: .actions) try container.encodeIfPresent(startsAt, forKey: .startsAt) + if let poiType { + try container.encode(poiType.rawValue, forKey: .poiType) + } if let condition { try container.encode(condition.irisScreamingCase(), forKey: .condition) } diff --git a/IrisCompanion/iris/Models/FeedStore.swift b/IrisCompanion/iris/Models/FeedStore.swift index 9bc5cb1..a207249 100644 --- a/IrisCompanion/iris/Models/FeedStore.swift +++ b/IrisCompanion/iris/Models/FeedStore.swift @@ -132,6 +132,7 @@ final class FeedStore { ttlSec: ttl, condition: card.condition, startsAt: card.startsAt, + poiType: card.poiType, bucket: card.bucket, actions: card.actions ) diff --git a/IrisCompanion/iris/Models/HeuristicRanker.swift b/IrisCompanion/iris/Models/HeuristicRanker.swift index 4a01d1b..7400f97 100644 --- a/IrisCompanion/iris/Models/HeuristicRanker.swift +++ b/IrisCompanion/iris/Models/HeuristicRanker.swift @@ -75,6 +75,7 @@ final class HeuristicRanker { ttlSec: max(1, best.item.ttlSec), condition: best.item.condition, startsAt: best.item.startsAt, + poiType: best.item.poiType, bucket: .rightNow, actions: ["DISMISS"] ) diff --git a/IrisCompanion/iris/Models/POIType.swift b/IrisCompanion/iris/Models/POIType.swift new file mode 100644 index 0000000..48caf56 --- /dev/null +++ b/IrisCompanion/iris/Models/POIType.swift @@ -0,0 +1,84 @@ +// +// POIType.swift +// iris +// +// Created by Codex. +// + +import Foundation + +enum POIType: String, Codable, CaseIterable, Sendable { + case animalService = "MKPOICategoryAnimalService" + case airport = "MKPOICategoryAirport" + case amusementPark = "MKPOICategoryAmusementPark" + case aquarium = "MKPOICategoryAquarium" + case atm = "MKPOICategoryATM" + case automotiveRepair = "MKPOICategoryAutomotiveRepair" + case bakery = "MKPOICategoryBakery" + case bank = "MKPOICategoryBank" + case baseball = "MKPOICategoryBaseball" + case basketball = "MKPOICategoryBasketball" + case beach = "MKPOICategoryBeach" + case beauty = "MKPOICategoryBeauty" + case bowling = "MKPOICategoryBowling" + case brewery = "MKPOICategoryBrewery" + case cafe = "MKPOICategoryCafe" + case campground = "MKPOICategoryCampground" + case carRental = "MKPOICategoryCarRental" + case castle = "MKPOICategoryCastle" + case conventionCenter = "MKPOICategoryConventionCenter" + case distillery = "MKPOICategoryDistillery" + case evCharger = "MKPOICategoryEVCharger" + case fairground = "MKPOICategoryFairground" + case fireStation = "MKPOICategoryFireStation" + case fishing = "MKPOICategoryFishing" + case fitnessCenter = "MKPOICategoryFitnessCenter" + case foodMarket = "MKPOICategoryFoodMarket" + case fortress = "MKPOICategoryFortress" + case gasStation = "MKPOICategoryGasStation" + case golf = "MKPOICategoryGolf" + case goKart = "MKPOICategoryGoKart" + case hiking = "MKPOICategoryHiking" + case hospital = "MKPOICategoryHospital" + case hotel = "MKPOICategoryHotel" + case kayaking = "MKPOICategoryKayaking" + case landmark = "MKPOICategoryLandmark" + case laundry = "MKPOICategoryLaundry" + case library = "MKPOICategoryLibrary" + case mailbox = "MKPOICategoryMailbox" + case marina = "MKPOICategoryMarina" + case miniGolf = "MKPOICategoryMiniGolf" + case movieTheater = "MKPOICategoryMovieTheater" + case museum = "MKPOICategoryMuseum" + case musicVenue = "MKPOICategoryMusicVenue" + case nationalMonument = "MKPOICategoryNationalMonument" + case nationalPark = "MKPOICategoryNationalPark" + case nightlife = "MKPOICategoryNightlife" + case park = "MKPOICategoryPark" + case parking = "MKPOICategoryParking" + case pharmacy = "MKPOICategoryPharmacy" + case planetarium = "MKPOICategoryPlanetarium" + case police = "MKPOICategoryPolice" + case postOffice = "MKPOICategoryPostOffice" + case publicTransport = "MKPOICategoryPublicTransport" + case restaurant = "MKPOICategoryRestaurant" + case restroom = "MKPOICategoryRestroom" + case rockClimbing = "MKPOICategoryRockClimbing" + case rvPark = "MKPOICategoryRVPark" + case school = "MKPOICategorySchool" + case skatePark = "MKPOICategorySkatePark" + case skating = "MKPOICategorySkating" + case skiing = "MKPOICategorySkiing" + case soccer = "MKPOICategorySoccer" + case spa = "MKPOICategorySpa" + case stadium = "MKPOICategoryStadium" + case store = "MKPOICategoryStore" + case surfing = "MKPOICategorySurfing" + case swimming = "MKPOICategorySwimming" + case tennis = "MKPOICategoryTennis" + case theater = "MKPOICategoryTheater" + case university = "MKPOICategoryUniversity" + case winery = "MKPOICategoryWinery" + case volleyball = "MKPOICategoryVolleyball" + case zoo = "MKPOICategoryZoo" +} diff --git a/IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift b/IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift index de40049..a16c9bb 100644 --- a/IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift +++ b/IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift @@ -162,18 +162,23 @@ final class ContextOrchestrator: NSObject, ObservableObject { return (true, "initial") } let now = Date() - if let last = lastRecomputeAt, now.timeIntervalSince(last) > 15 * 60 { - return (true, "timer_15m") + let speed = max(0, location.speed) + let moving = speed >= 1.0 + let maxInterval: TimeInterval = moving ? 5 * 60 : 15 * 60 + if let last = lastRecomputeAt, now.timeIntervalSince(last) > maxInterval { + return (true, moving ? "timer_5m_moving" : "timer_15m") } if let lastLoc = lastRecomputeLocation { let dist = location.distance(from: lastLoc) - if dist > 250 { - return (true, "moved_250m") + let distanceThreshold: CLLocationDistance = moving ? 100 : 250 + if dist > distanceThreshold { + return (true, moving ? "moved_100m_moving" : "moved_250m") } } if let lastAcc = lastRecomputeAccuracy, location.horizontalAccuracy > 0, lastAcc > 0 { - if lastAcc - location.horizontalAccuracy > 50 { - return (true, "accuracy_improved_50m") + let improvementThreshold: CLLocationAccuracy = moving ? 30 : 50 + if lastAcc - location.horizontalAccuracy > improvementThreshold { + return (true, moving ? "accuracy_improved_30m" : "accuracy_improved_50m") } } return (false, "no_trigger") @@ -217,6 +222,7 @@ final class ContextOrchestrator: NSObject, ObservableObject { var rightNowCandidates: [HeuristicRanker.Ranked] = [] var calendarItems: [FeedItem] = [] + var poiItems: [FeedItem] = [] var weatherNowItem: FeedItem? = nil var fetchFailed = false var wxDiagnostics: [String: String] = [:] @@ -317,8 +323,29 @@ final class ContextOrchestrator: NSObject, ObservableObject { } switch poiRes { - case .success: - break + case .success(let pois): + if pois.isEmpty { + logger.info("no points of interests found") + } + for poi in pois.prefix(2) { + let subtitle = poiSubtitle(for: poi) + let confidence = min(max(poi.confidence, 0.0), 1.0) + let item = FeedItem( + id: poi.id, + type: .poiNearby, + title: poi.name.truncated(maxLength: TextConstraints.titleMax), + subtitle: subtitle.truncated(maxLength: TextConstraints.subtitleMax), + priority: confidence, + ttlSec: max(1, poi.ttlSec), + condition: nil, + startsAt: nil, + poiType: poi.poiType, + bucket: .fyi, + actions: ["DISMISS"] + ) + poiItems.append(item) + rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: true)) + } case .failure(let error): fetchFailed = true logger.error("poi fetch failed: \(String(describing: error), privacy: .public)") @@ -364,7 +391,7 @@ final class ContextOrchestrator: NSObject, ObservableObject { lastWeatherDiagnostics = wxDiagnostics lastCalendarDiagnostics = calDiagnostics - logger.info("pipeline right_now_candidates=\(rightNowCandidates.count) calendar_items=\(calendarItems.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)") + logger.info("pipeline right_now_candidates=\(rightNowCandidates.count) calendar_items=\(calendarItems.count) poi_items=\(poiItems.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)") if fetchFailed, rightNowCandidates.isEmpty, calendarItems.isEmpty, weatherNowItem == nil { let fallbackFeed = store.getFeed(now: nowEpoch) @@ -399,6 +426,28 @@ final class ContextOrchestrator: NSObject, ObservableObject { ttlSec: max(1, item.ttlSec), condition: item.condition, startsAt: item.startsAt, + poiType: item.poiType, + bucket: .fyi, + actions: ["DISMISS"] + ) + }) + + let fyiPOI = poiItems + .filter { $0.id != winnerItem.id } + .filter { !store.isSuppressed(id: $0.id, type: $0.type, now: nowEpoch) } + .prefix(2) + + fyi.append(contentsOf: fyiPOI.map { item in + FeedItem( + id: item.id, + type: item.type, + title: item.title.truncated(maxLength: TextConstraints.titleMax), + subtitle: item.subtitle.truncated(maxLength: TextConstraints.subtitleMax), + priority: min(max(item.priority, 0.0), 1.0), + ttlSec: max(1, item.ttlSec), + condition: item.condition, + startsAt: item.startsAt, + poiType: item.poiType, bucket: .fyi, actions: ["DISMISS"] ) @@ -467,6 +516,22 @@ final class ContextOrchestrator: NSObject, ObservableObject { meta: FeedMeta(winnerId: base.meta.winnerId, unreadCount: cards.count) ) } + + private func poiSubtitle(for poi: POIDataSource.POI) -> String { + let distance = distanceText(meters: poi.distanceMeters) + let walk = "\(poi.walkingMinutes) min walk" + let parts = [poi.category, distance, walk] + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return parts.joined(separator: " • ") + } + + private func distanceText(meters: CLLocationDistance) -> String { + if meters >= 1000 { + return String(format: "%.1f km", meters / 1000.0) + } + return "\(Int(meters.rounded())) m" + } } extension ContextOrchestrator: CLLocationManagerDelegate {