4 Commits

Author SHA1 Message Date
0eb77b73c6 docs: add GPG signing instruction to AGENTS.md
Co-authored-by: Ona <no-reply@ona.com>
2026-01-18 20:39:26 +00:00
dfce846c9a Merge pull request #7 from kennethnym/feat/feed-controller-orchestration
feat(core): add FeedController orchestration layer
2026-01-18 20:30:27 +00:00
b73e603c90 feat(core): return RefreshResult from ContextBridge.refresh()
Surfaces provider errors through RefreshResult.errors instead of
silently ignoring them.

Co-authored-by: Ona <no-reply@ona.com>
2026-01-18 20:28:54 +00:00
037589cf4f refactor(core): rename getCurrentValue to fetchCurrentValue
Also use Promise.allSettled in ContextBridge.refresh() to handle
provider errors gracefully.

Co-authored-by: Ona <no-reply@ona.com>
2026-01-18 20:23:54 +00:00
10 changed files with 61 additions and 20 deletions

View File

@@ -39,3 +39,4 @@ Use Bun exclusively. Do not use npm or yarn.
- Branch: `feat/<task>`, `fix/<task>`, `ci/<task>`, etc. - Branch: `feat/<task>`, `fix/<task>`, `ci/<task>`, etc.
- Commits: conventional commit format, title <= 50 chars - Commits: conventional commit format, title <= 50 chars
- Signing: If `GPG_PRIVATE_KEY_PASSPHRASE` env var is available, use it to sign commits with `git commit -S`

View File

@@ -107,7 +107,7 @@ class LocationProvider implements ContextProvider<Location> {
return () => navigator.geolocation.clearWatch(watchId) return () => navigator.geolocation.clearWatch(watchId)
} }
async getCurrentValue(): Promise<Location> { async fetchCurrentValue(): Promise<Location> {
const pos = await new Promise<GeolocationPosition>((resolve, reject) => { const pos = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject) navigator.geolocation.getCurrentPosition(resolve, reject)
}) })

View File

@@ -5,6 +5,15 @@ interface ContextUpdatable {
pushContextUpdate(update: Partial<Context>): void pushContextUpdate(update: Partial<Context>): void
} }
export interface ProviderError {
key: string
error: Error
}
export interface RefreshResult {
errors: ProviderError[]
}
/** /**
* Bridges context providers to a feed controller. * Bridges context providers to a feed controller.
* *
@@ -55,18 +64,32 @@ export class ContextBridge {
/** /**
* Gathers current values from all providers and pushes to controller. * Gathers current values from all providers and pushes to controller.
* Use for manual refresh when user pulls to refresh. * Use for manual refresh when user pulls to refresh.
* Returns errors from providers that failed to fetch.
*/ */
async refresh(): Promise<void> { async refresh(): Promise<RefreshResult> {
const updates: Partial<Context> = {} const updates: Partial<Context> = {}
const errors: ProviderError[] = []
const entries = Array.from(this.providers.entries()) const entries = Array.from(this.providers.entries())
const values = await Promise.all(entries.map(([_, provider]) => provider.getCurrentValue())) const results = await Promise.allSettled(
entries.map(([_, provider]) => provider.fetchCurrentValue()),
)
entries.forEach(([key], i) => { entries.forEach(([key], i) => {
updates[key] = values[i] const result = results[i]
if (result?.status === "fulfilled") {
updates[key] = result.value
} else if (result?.status === "rejected") {
errors.push({
key,
error: result.reason instanceof Error ? result.reason : new Error(String(result.reason)),
})
}
}) })
this.controller.pushContextUpdate(updates) this.controller.pushContextUpdate(updates)
return { errors }
} }
/** /**

View File

@@ -16,7 +16,7 @@
* return () => navigator.geolocation.clearWatch(watchId) * return () => navigator.geolocation.clearWatch(watchId)
* } * }
* *
* async getCurrentValue(): Promise<Location> { * async fetchCurrentValue(): Promise<Location> {
* const pos = await getCurrentPosition() * const pos = await getCurrentPosition()
* return { lat: pos.coords.latitude, lng: pos.coords.longitude, accuracy: pos.coords.accuracy } * return { lat: pos.coords.latitude, lng: pos.coords.longitude, accuracy: pos.coords.accuracy }
* } * }
@@ -30,6 +30,6 @@ export interface ContextProvider<T = unknown> {
/** Subscribe to value changes. Returns cleanup function. */ /** Subscribe to value changes. Returns cleanup function. */
onUpdate(callback: (value: T) => void): () => void onUpdate(callback: (value: T) => void): () => void
/** Get current value on-demand (used for manual refresh). */ /** Fetch current value on-demand (used for manual refresh). */
getCurrentValue(): Promise<T> fetchCurrentValue(): Promise<T>
} }

View File

@@ -12,6 +12,7 @@ export type { DataSource } from "./data-source"
export type { ContextProvider } from "./context-provider" export type { ContextProvider } from "./context-provider"
// Context Bridge // Context Bridge
export type { ProviderError, RefreshResult } from "./context-bridge"
export { ContextBridge } from "./context-bridge" export { ContextBridge } from "./context-bridge"
// Reconciler // Reconciler

View File

@@ -111,7 +111,7 @@ function createLocationProvider(): SimulatedLocationProvider {
callback = null callback = null
} }
}, },
async getCurrentValue() { async fetchCurrentValue() {
return currentLocation return currentLocation
}, },
simulateUpdate(location: Location) { simulateUpdate(location: Location) {

View File

@@ -1,5 +1,5 @@
import type { Context, DataSource } from "@aris/core" import type { Context, DataSource } from "@aris/core"
import { TflApi, type ITflApi } from "./tfl-api.ts"
import type { import type {
StationLocation, StationLocation,
TflAlertData, TflAlertData,
@@ -10,6 +10,8 @@ import type {
TflLineId, TflLineId,
} from "./types.ts" } from "./types.ts"
import { TflApi, type ITflApi } from "./tfl-api.ts"
const SEVERITY_PRIORITY: Record<TflAlertSeverity, number> = { const SEVERITY_PRIORITY: Record<TflAlertSeverity, number> = {
closure: 100, closure: 100,
"major-delays": 80, "major-delays": 80,
@@ -22,7 +24,10 @@ function haversineDistance(lat1: number, lng1: number, lat2: number, lng2: numbe
const dLng = ((lng2 - lng1) * Math.PI) / 180 const dLng = ((lng2 - lng1) * Math.PI) / 180
const a = const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLng / 2) * Math.sin(dLng / 2) Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLng / 2) *
Math.sin(dLng / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c return R * c
} }
@@ -62,12 +67,19 @@ export class TflDataSource implements DataSource<TflAlertFeedItem, TflDataSource
} }
async query(context: Context, config: TflDataSourceConfig): Promise<TflAlertFeedItem[]> { async query(context: Context, config: TflDataSourceConfig): Promise<TflAlertFeedItem[]> {
const [statuses, stations] = await Promise.all([this.api.fetchLineStatuses(config.lines), this.api.fetchStations()]) const [statuses, stations] = await Promise.all([
this.api.fetchLineStatuses(config.lines),
this.api.fetchStations(),
])
const items: TflAlertFeedItem[] = statuses.map((status) => { const items: TflAlertFeedItem[] = statuses.map((status) => {
const closestStationDistance = const closestStationDistance = context.location
context.location ? ? findClosestStationDistance(
findClosestStationDistance(status.lineId, stations, context.location.lat, context.location.lng) status.lineId,
stations,
context.location.lat,
context.location.lng,
)
: null : null
const data: TflAlertData = { const data: TflAlertData = {

View File

@@ -1,11 +1,12 @@
import type { Context } from "@aris/core"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Context } from "@aris/core"
import { TflDataSource } from "./data-source.ts"
import type { ITflApi, TflLineStatus } from "./tfl-api.ts" import type { ITflApi, TflLineStatus } from "./tfl-api.ts"
import type { StationLocation, TflLineId } from "./types.ts" import type { StationLocation, TflLineId } from "./types.ts"
import fixtures from "../fixtures/tfl-responses.json" import fixtures from "../fixtures/tfl-responses.json"
import { TflDataSource } from "./data-source.ts"
// Mock API that returns fixture data // Mock API that returns fixture data
class FixtureTflApi implements ITflApi { class FixtureTflApi implements ITflApi {
@@ -109,9 +110,10 @@ describe("TfL Feed Items (using fixture data)", () => {
expect(typeof item.data.lineName).toBe("string") expect(typeof item.data.lineName).toBe("string")
expect(["minor-delays", "major-delays", "closure"]).toContain(item.data.severity) expect(["minor-delays", "major-delays", "closure"]).toContain(item.data.severity)
expect(typeof item.data.description).toBe("string") expect(typeof item.data.description).toBe("string")
expect(item.data.closestStationDistance === null || typeof item.data.closestStationDistance === "number").toBe( expect(
true, item.data.closestStationDistance === null ||
) typeof item.data.closestStationDistance === "number",
).toBe(true)
} }
}) })

View File

@@ -1,4 +1,5 @@
import { type } from "arktype" import { type } from "arktype"
import type { StationLocation, TflAlertSeverity } from "./types.ts" import type { StationLocation, TflAlertSeverity } from "./types.ts"
const TFL_API_BASE = "https://api.tfl.gov.uk" const TFL_API_BASE = "https://api.tfl.gov.uk"

View File

@@ -1,4 +1,5 @@
import type { FeedItem } from "@aris/core" import type { FeedItem } from "@aris/core"
import type { TflLineId } from "./tfl-api.ts" import type { TflLineId } from "./tfl-api.ts"
export type { TflLineId } from "./tfl-api.ts" export type { TflLineId } from "./tfl-api.ts"