Files
rzen 9b231a1978 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.
2026-07-14 20:30:46 -04:00

131 lines
5.1 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
)
}
}