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:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user