mirror of
https://github.com/kennethnym/aris.git
synced 2026-02-02 13:11:17 +00:00
feat(source-location): add LocationSource for push-based location context
Implements FeedSource interface. Accepts external location pushes, provides context to downstream sources, does not produce feed items. Supports configurable history size. Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
6
packages/aris-source-location/src/index.ts
Normal file
6
packages/aris-source-location/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
LocationSource,
|
||||
LocationKey,
|
||||
type Location,
|
||||
type LocationSourceOptions,
|
||||
} from "./location-source.ts"
|
||||
150
packages/aris-source-location/src/location-source.test.ts
Normal file
150
packages/aris-source-location/src/location-source.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
|
||||
import { LocationKey, LocationSource, type Location } from "./location-source.ts"
|
||||
|
||||
function createLocation(overrides: Partial<Location> = {}): Location {
|
||||
return {
|
||||
lat: 37.7749,
|
||||
lng: -122.4194,
|
||||
accuracy: 10,
|
||||
timestamp: new Date(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe("LocationSource", () => {
|
||||
describe("FeedSource interface", () => {
|
||||
test("has correct id", () => {
|
||||
const source = new LocationSource()
|
||||
expect(source.id).toBe("location")
|
||||
})
|
||||
|
||||
test("fetchItems always returns empty array", async () => {
|
||||
const source = new LocationSource()
|
||||
source.pushLocation(createLocation())
|
||||
|
||||
const items = await source.fetchItems()
|
||||
expect(items).toEqual([])
|
||||
})
|
||||
|
||||
test("fetchContext returns empty when no location", async () => {
|
||||
const source = new LocationSource()
|
||||
|
||||
const context = await source.fetchContext()
|
||||
expect(context).toEqual({})
|
||||
})
|
||||
|
||||
test("fetchContext returns location when available", async () => {
|
||||
const source = new LocationSource()
|
||||
const location = createLocation()
|
||||
source.pushLocation(location)
|
||||
|
||||
const context = await source.fetchContext()
|
||||
expect(context).toEqual({ [LocationKey]: location })
|
||||
})
|
||||
})
|
||||
|
||||
describe("pushLocation", () => {
|
||||
test("updates lastLocation", () => {
|
||||
const source = new LocationSource()
|
||||
expect(source.lastLocation).toBeNull()
|
||||
|
||||
const location = createLocation()
|
||||
source.pushLocation(location)
|
||||
|
||||
expect(source.lastLocation).toEqual(location)
|
||||
})
|
||||
|
||||
test("notifies listeners", () => {
|
||||
const source = new LocationSource()
|
||||
const listener = mock()
|
||||
|
||||
source.onContextUpdate(listener)
|
||||
|
||||
const location = createLocation()
|
||||
source.pushLocation(location)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith({ [LocationKey]: location })
|
||||
})
|
||||
})
|
||||
|
||||
describe("history", () => {
|
||||
test("default historySize is 1", () => {
|
||||
const source = new LocationSource()
|
||||
|
||||
source.pushLocation(createLocation({ lat: 1 }))
|
||||
source.pushLocation(createLocation({ lat: 2 }))
|
||||
|
||||
expect(source.locationHistory).toHaveLength(1)
|
||||
expect(source.lastLocation?.lat).toBe(2)
|
||||
})
|
||||
|
||||
test("respects configured historySize", () => {
|
||||
const source = new LocationSource({ historySize: 3 })
|
||||
|
||||
const loc1 = createLocation({ lat: 1 })
|
||||
const loc2 = createLocation({ lat: 2 })
|
||||
const loc3 = createLocation({ lat: 3 })
|
||||
|
||||
source.pushLocation(loc1)
|
||||
source.pushLocation(loc2)
|
||||
source.pushLocation(loc3)
|
||||
|
||||
expect(source.locationHistory).toEqual([loc1, loc2, loc3])
|
||||
})
|
||||
|
||||
test("evicts oldest when exceeding historySize", () => {
|
||||
const source = new LocationSource({ historySize: 2 })
|
||||
|
||||
const loc1 = createLocation({ lat: 1 })
|
||||
const loc2 = createLocation({ lat: 2 })
|
||||
const loc3 = createLocation({ lat: 3 })
|
||||
|
||||
source.pushLocation(loc1)
|
||||
source.pushLocation(loc2)
|
||||
source.pushLocation(loc3)
|
||||
|
||||
expect(source.locationHistory).toEqual([loc2, loc3])
|
||||
})
|
||||
|
||||
test("locationHistory is readonly", () => {
|
||||
const source = new LocationSource({ historySize: 3 })
|
||||
source.pushLocation(createLocation())
|
||||
|
||||
const history = source.locationHistory
|
||||
expect(Array.isArray(history)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("onContextUpdate", () => {
|
||||
test("returns cleanup function", () => {
|
||||
const source = new LocationSource()
|
||||
const listener = mock()
|
||||
|
||||
const cleanup = source.onContextUpdate(listener)
|
||||
|
||||
source.pushLocation(createLocation({ lat: 1 }))
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
|
||||
cleanup()
|
||||
|
||||
source.pushLocation(createLocation({ lat: 2 }))
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test("supports multiple listeners", () => {
|
||||
const source = new LocationSource()
|
||||
const listener1 = mock()
|
||||
const listener2 = mock()
|
||||
|
||||
source.onContextUpdate(listener1)
|
||||
source.onContextUpdate(listener2)
|
||||
|
||||
source.pushLocation(createLocation())
|
||||
|
||||
expect(listener1).toHaveBeenCalledTimes(1)
|
||||
expect(listener2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
86
packages/aris-source-location/src/location-source.ts
Normal file
86
packages/aris-source-location/src/location-source.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { Context, FeedSource } from "@aris/core"
|
||||
|
||||
import { contextKey, type ContextKey } from "@aris/core"
|
||||
|
||||
/**
|
||||
* Geographic coordinates with accuracy and timestamp.
|
||||
*/
|
||||
export interface Location {
|
||||
lat: number
|
||||
lng: number
|
||||
/** Accuracy in meters */
|
||||
accuracy: number
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export interface LocationSourceOptions {
|
||||
/** Number of locations to retain in history. Defaults to 1. */
|
||||
historySize?: number
|
||||
}
|
||||
|
||||
export const LocationKey: ContextKey<Location> = contextKey("location")
|
||||
|
||||
/**
|
||||
* A FeedSource that provides location context.
|
||||
*
|
||||
* This source accepts external location pushes and does not query location itself.
|
||||
* Use `pushLocation` to update the location from an external provider (e.g., GPS, network).
|
||||
*
|
||||
* Does not produce feed items - always returns empty array from `fetchItems`.
|
||||
*/
|
||||
export class LocationSource implements FeedSource {
|
||||
readonly id = "location"
|
||||
|
||||
private readonly historySize: number
|
||||
private locations: Location[] = []
|
||||
private listeners = new Set<(update: Partial<Context>) => void>()
|
||||
|
||||
constructor(options: LocationSourceOptions = {}) {
|
||||
this.historySize = options.historySize ?? 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a new location update. Notifies all context listeners.
|
||||
*/
|
||||
pushLocation(location: Location): void {
|
||||
this.locations.push(location)
|
||||
if (this.locations.length > this.historySize) {
|
||||
this.locations.shift()
|
||||
}
|
||||
this.listeners.forEach((listener) => {
|
||||
listener({ [LocationKey]: location })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Most recent location, or null if none pushed.
|
||||
*/
|
||||
get lastLocation(): Location | null {
|
||||
return this.locations[this.locations.length - 1] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Location history, oldest first. Length limited by `historySize`.
|
||||
*/
|
||||
get locationHistory(): readonly Location[] {
|
||||
return this.locations
|
||||
}
|
||||
|
||||
onContextUpdate(callback: (update: Partial<Context>) => void): () => void {
|
||||
this.listeners.add(callback)
|
||||
return () => {
|
||||
this.listeners.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
async fetchContext(): Promise<Partial<Context>> {
|
||||
if (this.lastLocation) {
|
||||
return { [LocationKey]: this.lastLocation }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
async fetchItems(): Promise<[]> {
|
||||
return []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user