Initial scaffold: context-aware notes app for iOS + macOS

XcodeGen project (Notes / NotesMac / NotesTests), Swift 6 strict concurrency,
iCloud-document storage via IndieSync with a SwiftData cache, automatic
capture context (location, place, time zone, device) on every note,
context-based relevance ranking with a 'Right here, right now' shelf,
tags, search, and the square.and.pencil-on-red app icon.
This commit is contained in:
2026-07-14 20:30:46 -04:00
commit 9b231a1978
42 changed files with 2126 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
import CoreLocation
import Foundation
import MapKit
/// Captures the "where am I, what time is it" snapshot used both when a
/// note is taken (stamped into the note) and when notes are recalled (fed to
/// the relevance ranking). Location is best-effort: denied or unavailable
/// location still yields a usable context (time zone, device).
@Observable
@MainActor
final class ContextService {
/// Latest context snapshot. Updated by `refresh()`.
private(set) var current: CaptureContext = .empty
/// Raw location behind `current`, for distance math at recall time.
private(set) var location: CLLocation?
private(set) var isRefreshing = false
private(set) var authorizationDenied = false
init() {
current.device = Self.deviceName
current.timeZoneID = TimeZone.current.identifier
}
static var deviceName: String {
#if os(macOS)
"Mac"
#else
"iPhone"
#endif
}
/// One-shot refresh: grab a current location (asking for when-in-use
/// authorization if needed), then reverse-geocode it into place names.
/// Bounded gives up after ~10 seconds rather than spinning.
func refresh() async {
guard !isRefreshing else { return }
isRefreshing = true
defer { isRefreshing = false }
current.device = Self.deviceName
current.timeZoneID = TimeZone.current.identifier
guard let location = await acquireLocation() else { return }
self.location = location
current.latitude = location.coordinate.latitude
current.longitude = location.coordinate.longitude
current.horizontalAccuracy = location.horizontalAccuracy
await reverseGeocode(location)
}
/// First fix from `CLLocationUpdate.liveUpdates()`, raced against a
/// 10-second deadline. `CLServiceSession` holds the when-in-use
/// authorization request open for the duration.
private func acquireLocation() async -> CLLocation? {
// CLServiceSession is unavailable on macOS; there liveUpdates itself
// triggers the authorization prompt.
#if os(iOS)
let session = CLServiceSession(authorization: .whenInUse)
defer { session.invalidate() }
#endif
let fix = Task {
for try await update in CLLocationUpdate.liveUpdates() {
if update.authorizationDenied || update.authorizationDeniedGlobally {
await MainActor.run { self.authorizationDenied = true }
return nil as CLLocation?
}
if let location = update.location {
return location
}
}
return nil
}
let deadline = Task {
try? await Task.sleep(for: .seconds(10))
fix.cancel()
}
defer { deadline.cancel() }
let location = (try? await fix.value) ?? nil
if location != nil { authorizationDenied = false }
return location
}
private func reverseGeocode(_ location: CLLocation) async {
guard let request = MKReverseGeocodingRequest(location: location) else { return }
guard let item = try? await request.mapItems.first else { return }
current.placeName = item.name
current.thoroughfare = item.address?.shortAddress
current.locality = item.addressRepresentations?.cityName
}
}
+130
View File
@@ -0,0 +1,130 @@
import CoreLocation
import Foundation
/// The signals relevance scoring reads from a note. A plain value type so
/// scoring is testable without SwiftData.
struct RelevanceSignals: Sendable, Equatable {
var latitude: Double?
var longitude: Double?
var placeName: String?
var locality: String?
var createdAt: Date
var timeZoneID: String?
}
/// The recall-time context a query scores against.
struct RelevanceQuery: Sendable {
var latitude: Double?
var longitude: Double?
var placeName: String?
var locality: String?
var date: Date = Date()
var timeZone: TimeZone = .current
}
/// Context-based ranking: given "where am I, what time is it" now, how likely
/// is this note the one the user wants at their fingertips?
///
/// The score is a sum of independent affinity terms, each decaying smoothly
/// from its ideal (same spot, same hour, just written). Weights are tuned so
/// physical proximity dominates being back at the coffee shop should
/// surface the barista's name even if the note is months old.
enum Relevance {
/// Score at or above which a note belongs in the "right here, right now"
/// shelf.
static let surfacingThreshold = 1.0
static func score(_ note: RelevanceSignals, query: RelevanceQuery) -> Double {
var score = 0.0
// Proximity: exponential decay with ~250 m scale full weight inside
// a venue, negligible a few kilometers away.
if let noteLat = note.latitude, let noteLon = note.longitude,
let hereLat = query.latitude, let hereLon = query.longitude {
let meters = CLLocation(latitude: noteLat, longitude: noteLon)
.distance(from: CLLocation(latitude: hereLat, longitude: hereLon))
score += 3.0 * exp(-meters / 250)
}
// Same named place (exact, case-insensitive) reverse geocoding is
// stable enough that this is a strong "you are back here" signal even
// when GPS wobbles.
if let notePlace = note.placeName, let herePlace = query.placeName,
!notePlace.isEmpty, notePlace.caseInsensitiveCompare(herePlace) == .orderedSame {
score += 1.5
} else if let noteCity = note.locality, let hereCity = query.locality,
!noteCity.isEmpty, noteCity.caseInsensitiveCompare(hereCity) == .orderedSame {
score += 0.3
}
// Time-of-day affinity: Gaussian over circular hour distance
// (σ 2.5 h) a "morning" note resurfaces in the morning.
let noteHour = localHour(of: note.createdAt, timeZoneID: note.timeZoneID)
let queryHour = Double(Calendar.current.component(.hour, from: query.date))
+ Double(Calendar.current.component(.minute, from: query.date)) / 60
let hourDelta = circularHourDistance(noteHour, queryHour)
score += 0.6 * exp(-(hourDelta * hourDelta) / (2 * 2.5 * 2.5))
// Same weekday: weak habitual signal (the Saturday farmers market).
var noteCalendar = Calendar.current
if let id = note.timeZoneID, let zone = TimeZone(identifier: id) {
noteCalendar.timeZone = zone
}
var queryCalendar = Calendar.current
queryCalendar.timeZone = query.timeZone
if noteCalendar.component(.weekday, from: note.createdAt)
== queryCalendar.component(.weekday, from: query.date) {
score += 0.2
}
// Recency: fresh notes float regardless of place (~45-day half-life
// feel; purely a tiebreaker against the location terms).
let ageDays = max(0, query.date.timeIntervalSince(note.createdAt)) / 86_400
score += 0.5 * exp(-ageDays / 45)
return score
}
/// Hour-of-day (fractional) of `date` in the zone the note was taken in,
/// falling back to the current zone.
private static func localHour(of date: Date, timeZoneID: String?) -> Double {
var calendar = Calendar.current
if let timeZoneID, let zone = TimeZone(identifier: timeZoneID) {
calendar.timeZone = zone
}
let hour = calendar.component(.hour, from: date)
let minute = calendar.component(.minute, from: date)
return Double(hour) + Double(minute) / 60
}
/// Distance between two hours on the 24-hour circle (0...12).
private static func circularHourDistance(_ a: Double, _ b: Double) -> Double {
let diff = abs(a - b).truncatingRemainder(dividingBy: 24)
return min(diff, 24 - diff)
}
}
extension RelevanceSignals {
init(entity: NoteEntity) {
self.init(
latitude: entity.latitude,
longitude: entity.longitude,
placeName: entity.placeName,
locality: entity.locality,
createdAt: entity.createdAt,
timeZoneID: entity.timeZoneID
)
}
}
extension RelevanceQuery {
@MainActor
init(context service: ContextService) {
self.init(
latitude: service.current.latitude,
longitude: service.current.longitude,
placeName: service.current.placeName,
locality: service.current.locality
)
}
}