Compare commits

..

1 Commits

Author SHA1 Message Date
21b653b029 feat(core): add RenderedFeedItem type with JRX UI support
Add RenderedFeedItem that extends FeedItem with a ui: JrxNode field
for client-side rendering. Add @nym.sh/jrx and @json-render/core as
peer dependencies on @aelis/core.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-11 23:29:50 +00:00
90 changed files with 980 additions and 1933 deletions

View File

@@ -1,42 +0,0 @@
name: Build waitlist website
on:
push:
branches: [master]
paths:
- apps/waitlist-website/**
- .github/workflows/build-waitlist-website.yml
workflow_dispatch:
env:
REGISTRY: cr.nym.sh
IMAGE_NAME: aelis-waitlist-website
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: apps/waitlist-website
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -21,4 +21,4 @@ jobs:
run: bun install --frozen-lockfile
- name: Run tests
run: bun run test
run: bun test

View File

@@ -1,336 +0,0 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
import { contextKey } from "@aelis/core"
import { describe, expect, spyOn, test } from "bun:test"
import { Hono } from "hono"
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
import { UserSessionManager } from "../session/index.ts"
import { registerFeedHttpHandlers } from "./http.ts"
interface FeedResponse {
items: Array<{
id: string
type: string
priority: number
timestamp: string
data: Record<string, unknown>
}>
errors: Array<{ sourceId: string; error: string }>
}
function createStubSource(
id: string,
items: FeedItem[] = [],
contextEntries: readonly ContextEntry[] | null = null,
): FeedSource {
return {
id,
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
},
async executeAction(): Promise<unknown> {
return undefined
},
async fetchContext(): Promise<readonly ContextEntry[] | null> {
return contextEntries
},
async fetchItems() {
return items
},
}
}
function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
const app = new Hono()
registerFeedHttpHandlers(app, {
sessionManager,
authSessionMiddleware: mockAuthSessionMiddleware(userId),
})
return app
}
describe("GET /api/feed", () => {
test("returns 401 without auth", async () => {
const manager = new UserSessionManager({ providers: [] })
const app = buildTestApp(manager)
const res = await app.request("/api/feed")
expect(res.status).toBe(401)
})
test("returns cached feed when available", async () => {
const items: FeedItem[] = [
{
id: "item-1",
sourceId: "test",
type: "test",
priority: 0.8,
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
},
]
const manager = new UserSessionManager({
providers: [async () => createStubSource("test", items)],
})
const app = buildTestApp(manager, "user-1")
// Prime the cache
const session = await manager.getOrCreate("user-1")
await session.engine.refresh()
expect(session.engine.lastFeed()).not.toBeNull()
const res = await app.request("/api/feed")
expect(res.status).toBe(200)
const body = (await res.json()) as FeedResponse
expect(body.items).toHaveLength(1)
expect(body.items[0]!.id).toBe("item-1")
expect(body.items[0]!.type).toBe("test")
expect(body.items[0]!.priority).toBe(0.8)
expect(body.items[0]!.timestamp).toBe("2025-01-01T00:00:00.000Z")
expect(body.errors).toHaveLength(0)
})
test("forces refresh when no cached feed", async () => {
const items: FeedItem[] = [
{
id: "fresh-1",
sourceId: "test",
type: "test",
priority: 0.5,
timestamp: new Date("2025-06-01T12:00:00.000Z"),
data: { fresh: true },
},
]
const manager = new UserSessionManager({
providers: [async () => createStubSource("test", items)],
})
const app = buildTestApp(manager, "user-1")
// No prior refresh — lastFeed() returns null, handler should call refresh()
const res = await app.request("/api/feed")
expect(res.status).toBe(200)
const body = (await res.json()) as FeedResponse
expect(body.items).toHaveLength(1)
expect(body.items[0]!.id).toBe("fresh-1")
expect(body.items[0]!.data.fresh).toBe(true)
expect(body.errors).toHaveLength(0)
})
test("serializes source errors as message strings", async () => {
const failingSource: FeedSource = {
id: "failing",
async listActions() {
return {}
},
async executeAction() {
return undefined
},
async fetchContext() {
return null
},
async fetchItems() {
throw new Error("connection timeout")
},
}
const manager = new UserSessionManager({ providers: [async () => failingSource] })
const app = buildTestApp(manager, "user-1")
const res = await app.request("/api/feed")
expect(res.status).toBe(200)
const body = (await res.json()) as FeedResponse
expect(body.items).toHaveLength(0)
expect(body.errors).toHaveLength(1)
expect(body.errors[0]!.sourceId).toBe("failing")
expect(body.errors[0]!.error).toBe("connection timeout")
})
test("returns 503 when all providers fail", async () => {
const manager = new UserSessionManager({
providers: [
async () => {
throw new Error("provider down")
},
],
})
const app = buildTestApp(manager, "user-1")
const spy = spyOn(console, "error").mockImplementation(() => {})
const res = await app.request("/api/feed")
expect(res.status).toBe(503)
const body = (await res.json()) as { error: string }
expect(body.error).toBe("Service unavailable")
spy.mockRestore()
})
})
describe("GET /api/context", () => {
const weatherKey = contextKey("aelis.weather", "weather")
const weatherData = { temperature: 20, condition: "Clear" }
const contextEntries: readonly ContextEntry[] = [[weatherKey, weatherData]]
// The mock auth middleware always injects this hardcoded user ID
const mockUserId = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn"
async function buildContextApp(userId?: string) {
const manager = new UserSessionManager({
providers: [async () => createStubSource("weather", [], contextEntries)],
})
const app = buildTestApp(manager, userId)
const session = await manager.getOrCreate(mockUserId)
return { app, session }
}
test("returns 401 without auth", async () => {
const manager = new UserSessionManager({ providers: [] })
const app = buildTestApp(manager)
const res = await app.request('/api/context?key=["aelis.weather","weather"]')
expect(res.status).toBe(401)
})
test("returns 400 when key param is missing", async () => {
const { app } = await buildContextApp("user-1")
const res = await app.request("/api/context")
expect(res.status).toBe(400)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("key")
})
test("returns 400 when key is invalid JSON", async () => {
const { app } = await buildContextApp("user-1")
const res = await app.request("/api/context?key=notjson")
expect(res.status).toBe(400)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("key")
})
test("returns 400 when key is not an array", async () => {
const { app } = await buildContextApp("user-1")
const res = await app.request('/api/context?key="string"')
expect(res.status).toBe(400)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("key")
})
test("returns 400 when key contains invalid element types", async () => {
const { app } = await buildContextApp("user-1")
const res = await app.request("/api/context?key=[true,null,[1,2]]")
expect(res.status).toBe(400)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("key")
})
test("returns 400 when key is an empty array", async () => {
const { app } = await buildContextApp("user-1")
const res = await app.request("/api/context?key=[]")
expect(res.status).toBe(400)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("key")
})
test("returns 400 when match param is invalid", async () => {
const { app } = await buildContextApp("user-1")
const res = await app.request('/api/context?key=["aelis.weather"]&match=invalid')
expect(res.status).toBe(400)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("match")
})
test("returns exact match with match=exact", async () => {
const { app, session } = await buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["aelis.weather","weather"]&match=exact')
expect(res.status).toBe(200)
const body = (await res.json()) as { match: string; value: unknown }
expect(body.match).toBe("exact")
expect(body.value).toEqual(weatherData)
})
test("returns 404 with match=exact when only prefix would match", async () => {
const { app, session } = await buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["aelis.weather"]&match=exact')
expect(res.status).toBe(404)
})
test("returns prefix match with match=prefix", async () => {
const { app, session } = await buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["aelis.weather"]&match=prefix')
expect(res.status).toBe(200)
const body = (await res.json()) as {
match: string
entries: Array<{ key: unknown[]; value: unknown }>
}
expect(body.match).toBe("prefix")
expect(body.entries).toHaveLength(1)
expect(body.entries[0]!.key).toEqual(["aelis.weather", "weather"])
expect(body.entries[0]!.value).toEqual(weatherData)
})
test("default mode returns exact match when available", async () => {
const { app, session } = await buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["aelis.weather","weather"]')
expect(res.status).toBe(200)
const body = (await res.json()) as { match: string; value: unknown }
expect(body.match).toBe("exact")
expect(body.value).toEqual(weatherData)
})
test("default mode falls back to prefix when no exact match", async () => {
const { app, session } = await buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["aelis.weather"]')
expect(res.status).toBe(200)
const body = (await res.json()) as {
match: string
entries: Array<{ key: unknown[]; value: unknown }>
}
expect(body.match).toBe("prefix")
expect(body.entries).toHaveLength(1)
expect(body.entries[0]!.value).toEqual(weatherData)
})
test("returns 404 when neither exact nor prefix matches", async () => {
const { app, session } = await buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["nonexistent"]')
expect(res.status).toBe(404)
const body = (await res.json()) as { error: string }
expect(body.error).toBe("Context key not found")
})
})

View File

@@ -1,133 +0,0 @@
import type { Context, Hono } from "hono"
import { contextKey } from "@aelis/core"
import { createMiddleware } from "hono/factory"
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
import type { UserSessionManager } from "../session/index.ts"
type Env = {
Variables: {
sessionManager: UserSessionManager
}
}
interface FeedHttpHandlersDeps {
sessionManager: UserSessionManager
authSessionMiddleware: AuthSessionMiddleware
}
export function registerFeedHttpHandlers(
app: Hono,
{ sessionManager, authSessionMiddleware }: FeedHttpHandlersDeps,
) {
const inject = createMiddleware<Env>(async (c, next) => {
c.set("sessionManager", sessionManager)
await next()
})
app.get("/api/feed", inject, authSessionMiddleware, handleGetFeed)
app.get("/api/context", inject, authSessionMiddleware, handleGetContext)
}
async function handleGetFeed(c: Context<Env>) {
const user = c.get("user")!
const sessionManager = c.get("sessionManager")
let session
try {
session = await sessionManager.getOrCreate(user.id)
} catch (err) {
console.error("[handleGetFeed] Failed to create session:", err)
return c.json({ error: "Service unavailable" }, 503)
}
const feed = await session.feed()
return c.json({
items: feed.items,
errors: feed.errors.map((e) => ({
sourceId: e.sourceId,
error: e.error.message,
})),
})
}
async function handleGetContext(c: Context<Env>) {
const keyParam = c.req.query("key")
if (!keyParam) {
return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400)
}
let parsed: unknown
try {
parsed = JSON.parse(keyParam)
} catch {
return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400)
}
if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isContextKeyPart)) {
return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400)
}
const matchParam = c.req.query("match")
if (matchParam !== undefined && matchParam !== "exact" && matchParam !== "prefix") {
return c.json({ error: 'Invalid "match" parameter: must be "exact" or "prefix"' }, 400)
}
const user = c.get("user")!
const sessionManager = c.get("sessionManager")
let session
try {
session = await sessionManager.getOrCreate(user.id)
} catch (err) {
console.error("[handleGetContext] Failed to create session:", err)
return c.json({ error: "Service unavailable" }, 503)
}
const context = session.engine.currentContext()
const key = contextKey(...parsed)
if (matchParam === "exact") {
const value = context.get(key)
if (value === undefined) {
return c.json({ error: "Context key not found" }, 404)
}
return c.json({ match: "exact", value })
}
if (matchParam === "prefix") {
const entries = context.find(key)
if (entries.length === 0) {
return c.json({ error: "Context key not found" }, 404)
}
return c.json({ match: "prefix", entries })
}
// Default: single find() covers both exact and prefix matches
const entries = context.find(key)
if (entries.length === 0) {
return c.json({ error: "Context key not found" }, 404)
}
// If exactly one result with the same key length, treat as exact match
if (entries.length === 1 && entries[0]!.key.length === parsed.length) {
return c.json({ match: "exact", value: entries[0]!.value })
}
return c.json({ match: "prefix", entries })
}
/** Validates that a value is a valid ContextKeyPart (string, number, or plain object of primitives). */
function isContextKeyPart(value: unknown): boolean {
if (typeof value === "string" || typeof value === "number") {
return true
}
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
return Object.values(value).every(
(v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean",
)
}
return false
}

View File

@@ -9,7 +9,6 @@ import { mergeEnhancement } from "./merge.ts"
function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
return {
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-01T00:00:00Z"),
data: { value: 42 },

View File

@@ -2,8 +2,6 @@ import type { FeedItem } from "@aelis/core"
import type { EnhancementResult } from "./schema.ts"
const ENHANCEMENT_SOURCE_ID = "aelis.enhancement"
/**
* Merges an EnhancementResult into feed items.
*
@@ -12,11 +10,7 @@ const ENHANCEMENT_SOURCE_ID = "aelis.enhancement"
* - Returns a new array (no mutation)
* - Ignores fills for items/slots that don't exist
*/
export function mergeEnhancement(
items: FeedItem[],
result: EnhancementResult,
currentTime: Date,
): FeedItem[] {
export function mergeEnhancement(items: FeedItem[], result: EnhancementResult, currentTime: Date): FeedItem[] {
const merged = items.map((item) => {
const fills = result.slotFills[item.id]
if (!fills || !item.slots) return item
@@ -37,7 +31,6 @@ export function mergeEnhancement(
for (const synthetic of result.syntheticItems) {
merged.push({
id: synthetic.id,
sourceId: ENHANCEMENT_SOURCE_ID,
type: synthetic.type,
timestamp: currentTime,
data: { text: synthetic.text },

View File

@@ -7,7 +7,6 @@ import { buildPrompt, hasUnfilledSlots } from "./prompt-builder.ts"
function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
return {
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-01T00:00:00Z"),
data: { value: 42 },
@@ -61,9 +60,7 @@ describe("buildPrompt", () => {
expect(parsed.items).toHaveLength(1)
expect((parsed.items as Array<Record<string, unknown>>)[0]!.id).toBe("item-1")
expect((parsed.items as Array<Record<string, unknown>>)[0]!.slots).toEqual({
insight: "Weather insight",
})
expect((parsed.items as Array<Record<string, unknown>>)[0]!.slots).toEqual({ insight: "Weather insight" })
expect((parsed.items as Array<Record<string, unknown>>)[0]!.type).toBeUndefined()
expect(parsed.context).toHaveLength(0)
})

View File

@@ -0,0 +1,144 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
import { describe, expect, test } from "bun:test"
import { Hono } from "hono"
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
import { UserSessionManager } from "../session/index.ts"
import { registerFeedHttpHandlers } from "./http.ts"
interface FeedResponse {
items: Array<{
id: string
type: string
priority: number
timestamp: string
data: Record<string, unknown>
}>
errors: Array<{ sourceId: string; error: string }>
}
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
return {
id,
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
},
async executeAction(): Promise<unknown> {
return undefined
},
async fetchContext(): Promise<readonly ContextEntry[] | null> {
return null
},
async fetchItems() {
return items
},
}
}
function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
const app = new Hono()
registerFeedHttpHandlers(app, {
sessionManager,
authSessionMiddleware: mockAuthSessionMiddleware(userId),
})
return app
}
describe("GET /api/feed", () => {
test("returns 401 without auth", async () => {
const manager = new UserSessionManager({ providers: [] })
const app = buildTestApp(manager)
const res = await app.request("/api/feed")
expect(res.status).toBe(401)
})
test("returns cached feed when available", async () => {
const items: FeedItem[] = [
{
id: "item-1",
type: "test",
priority: 0.8,
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
},
]
const manager = new UserSessionManager({
providers: [() => createStubSource("test", items)],
})
const app = buildTestApp(manager, "user-1")
// Prime the cache
const session = manager.getOrCreate("user-1")
await session.engine.refresh()
expect(session.engine.lastFeed()).not.toBeNull()
const res = await app.request("/api/feed")
expect(res.status).toBe(200)
const body = (await res.json()) as FeedResponse
expect(body.items).toHaveLength(1)
expect(body.items[0]!.id).toBe("item-1")
expect(body.items[0]!.type).toBe("test")
expect(body.items[0]!.priority).toBe(0.8)
expect(body.items[0]!.timestamp).toBe("2025-01-01T00:00:00.000Z")
expect(body.errors).toHaveLength(0)
})
test("forces refresh when no cached feed", async () => {
const items: FeedItem[] = [
{
id: "fresh-1",
type: "test",
priority: 0.5,
timestamp: new Date("2025-06-01T12:00:00.000Z"),
data: { fresh: true },
},
]
const manager = new UserSessionManager({
providers: [() => createStubSource("test", items)],
})
const app = buildTestApp(manager, "user-1")
// No prior refresh — lastFeed() returns null, handler should call refresh()
const res = await app.request("/api/feed")
expect(res.status).toBe(200)
const body = (await res.json()) as FeedResponse
expect(body.items).toHaveLength(1)
expect(body.items[0]!.id).toBe("fresh-1")
expect(body.items[0]!.data.fresh).toBe(true)
expect(body.errors).toHaveLength(0)
})
test("serializes source errors as message strings", async () => {
const failingSource: FeedSource = {
id: "failing",
async listActions() {
return {}
},
async executeAction() {
return undefined
},
async fetchContext() {
return null
},
async fetchItems() {
throw new Error("connection timeout")
},
}
const manager = new UserSessionManager({ providers: [() => failingSource] })
const app = buildTestApp(manager, "user-1")
const res = await app.request("/api/feed")
expect(res.status).toBe(200)
const body = (await res.json()) as FeedResponse
expect(body.items).toHaveLength(0)
expect(body.errors).toHaveLength(1)
expect(body.errors[0]!.sourceId).toBe("failing")
expect(body.errors[0]!.error).toBe("connection timeout")
})
})

View File

@@ -0,0 +1,45 @@
import type { Context, Hono } from "hono"
import { createMiddleware } from "hono/factory"
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
import type { UserSessionManager } from "../session/index.ts"
type Env = {
Variables: {
sessionManager: UserSessionManager
}
}
interface FeedHttpHandlersDeps {
sessionManager: UserSessionManager
authSessionMiddleware: AuthSessionMiddleware
}
export function registerFeedHttpHandlers(
app: Hono,
{ sessionManager, authSessionMiddleware }: FeedHttpHandlersDeps,
) {
const inject = createMiddleware<Env>(async (c, next) => {
c.set("sessionManager", sessionManager)
await next()
})
app.get("/api/feed", inject, authSessionMiddleware, handleGetFeed)
}
async function handleGetFeed(c: Context<Env>) {
const user = c.get("user")!
const sessionManager = c.get("sessionManager")
const session = sessionManager.getOrCreate(user.id)
const feed = await session.feed()
return c.json({
items: feed.items,
errors: feed.errors.map((e) => ({
sourceId: e.sourceId,
error: e.error.message,
})),
})
}

View File

@@ -44,15 +44,7 @@ async function handleUpdateLocation(c: Context<Env>) {
const user = c.get("user")!
const sessionManager = c.get("sessionManager")
let session
try {
session = await sessionManager.getOrCreate(user.id)
} catch (err) {
console.error("[handleUpdateLocation] Failed to create session:", err)
return c.json({ error: "Service unavailable" }, 503)
}
const session = sessionManager.getOrCreate(user.id)
await session.engine.executeAction("aelis.location", "update-location", {
lat: result.lat,
lng: result.lng,

View File

@@ -3,9 +3,9 @@ import { Hono } from "hono"
import { registerAuthHandlers } from "./auth/http.ts"
import { mockAuthSessionMiddleware, requireSession } from "./auth/session-middleware.ts"
import { registerFeedHttpHandlers } from "./engine/http.ts"
import { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
import { createLlmClient } from "./enhancement/llm-client.ts"
import { registerFeedHttpHandlers } from "./feed/http.ts"
import { registerLocationHttpHandlers } from "./location/http.ts"
import { UserSessionManager } from "./session/index.ts"
import { WeatherSourceProvider } from "./weather/provider.ts"
@@ -26,7 +26,7 @@ function main() {
const sessionManager = new UserSessionManager({
providers: [
async () => new LocationSource(),
() => new LocationSource(),
new WeatherSourceProvider({
credentials: {
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,

View File

@@ -1,9 +1,9 @@
import type { FeedSource } from "@aelis/core"
export interface FeedSourceProvider {
feedSourceForUser(userId: string): Promise<FeedSource>
feedSourceForUser(userId: string): FeedSource
}
export type FeedSourceProviderFn = (userId: string) => Promise<FeedSource>
export type FeedSourceProviderFn = (userId: string) => FeedSource
export type FeedSourceProviderInput = FeedSourceProvider | FeedSourceProviderFn

View File

@@ -1,7 +1,7 @@
import type { WeatherKitClient, WeatherKitResponse } from "@aelis/source-weatherkit"
import { LocationSource } from "@aelis/source-location"
import { describe, expect, mock, spyOn, test } from "bun:test"
import { describe, expect, mock, test } from "bun:test"
import { WeatherSourceProvider } from "../weather/provider.ts"
import { UserSessionManager } from "./user-session-manager.ts"
@@ -11,38 +11,38 @@ const mockWeatherClient: WeatherKitClient = {
}
describe("UserSessionManager", () => {
test("getOrCreate creates session on first call", async () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
test("getOrCreate creates session on first call", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session = await manager.getOrCreate("user-1")
const session = manager.getOrCreate("user-1")
expect(session).toBeDefined()
expect(session.engine).toBeDefined()
})
test("getOrCreate returns same session for same user", async () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
test("getOrCreate returns same session for same user", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = await manager.getOrCreate("user-1")
const session2 = await manager.getOrCreate("user-1")
const session1 = manager.getOrCreate("user-1")
const session2 = manager.getOrCreate("user-1")
expect(session1).toBe(session2)
})
test("getOrCreate returns different sessions for different users", async () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
test("getOrCreate returns different sessions for different users", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = await manager.getOrCreate("user-1")
const session2 = await manager.getOrCreate("user-2")
const session1 = manager.getOrCreate("user-1")
const session2 = manager.getOrCreate("user-2")
expect(session1).not.toBe(session2)
})
test("each user gets independent source instances", async () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
test("each user gets independent source instances", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = await manager.getOrCreate("user-1")
const session2 = await manager.getOrCreate("user-2")
const session1 = manager.getOrCreate("user-1")
const session2 = manager.getOrCreate("user-2")
const source1 = session1.getSource<LocationSource>("aelis.location")
const source2 = session2.getSource<LocationSource>("aelis.location")
@@ -50,58 +50,58 @@ describe("UserSessionManager", () => {
expect(source1).not.toBe(source2)
})
test("remove destroys session and allows re-creation", async () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
test("remove destroys session and allows re-creation", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = await manager.getOrCreate("user-1")
const session1 = manager.getOrCreate("user-1")
manager.remove("user-1")
const session2 = await manager.getOrCreate("user-1")
const session2 = manager.getOrCreate("user-1")
expect(session1).not.toBe(session2)
})
test("remove is no-op for unknown user", () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
expect(() => manager.remove("unknown")).not.toThrow()
})
test("accepts function providers", async () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session = await manager.getOrCreate("user-1")
const session = manager.getOrCreate("user-1")
const result = await session.engine.refresh()
expect(result.errors).toHaveLength(0)
})
test("accepts object providers", async () => {
test("accepts object providers", () => {
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
const manager = new UserSessionManager({
providers: [async () => new LocationSource(), provider],
providers: [() => new LocationSource(), provider],
})
const session = await manager.getOrCreate("user-1")
const session = manager.getOrCreate("user-1")
expect(session.getSource("aelis.weather")).toBeDefined()
})
test("accepts mixed providers", async () => {
test("accepts mixed providers", () => {
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
const manager = new UserSessionManager({
providers: [async () => new LocationSource(), provider],
providers: [() => new LocationSource(), provider],
})
const session = await manager.getOrCreate("user-1")
const session = manager.getOrCreate("user-1")
expect(session.getSource("aelis.location")).toBeDefined()
expect(session.getSource("aelis.weather")).toBeDefined()
})
test("refresh returns feed result through session", async () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session = await manager.getOrCreate("user-1")
const session = manager.getOrCreate("user-1")
const result = await session.engine.refresh()
expect(result).toHaveProperty("context")
@@ -111,9 +111,9 @@ describe("UserSessionManager", () => {
})
test("location update via executeAction works", async () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session = await manager.getOrCreate("user-1")
const session = manager.getOrCreate("user-1")
await session.engine.executeAction("aelis.location", "update-location", {
lat: 51.5074,
lng: -0.1278,
@@ -126,10 +126,10 @@ describe("UserSessionManager", () => {
})
test("subscribe receives updates after location push", async () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const callback = mock()
const session = await manager.getOrCreate("user-1")
const session = manager.getOrCreate("user-1")
session.engine.subscribe(callback)
await session.engine.executeAction("aelis.location", "update-location", {
@@ -146,16 +146,16 @@ describe("UserSessionManager", () => {
})
test("remove stops reactive updates", async () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const callback = mock()
const session = await manager.getOrCreate("user-1")
const session = manager.getOrCreate("user-1")
session.engine.subscribe(callback)
manager.remove("user-1")
// Create new session and push location — old callback should not fire
const session2 = await manager.getOrCreate("user-1")
const session2 = manager.getOrCreate("user-1")
await session2.engine.executeAction("aelis.location", "update-location", {
lat: 51.5074,
lng: -0.1278,
@@ -167,93 +167,4 @@ describe("UserSessionManager", () => {
expect(callback).not.toHaveBeenCalled()
})
test("creates session with successful providers when some fail", async () => {
const manager = new UserSessionManager({
providers: [
async () => new LocationSource(),
async () => {
throw new Error("provider failed")
},
],
})
const spy = spyOn(console, "error").mockImplementation(() => {})
const session = await manager.getOrCreate("user-1")
expect(session).toBeDefined()
expect(session.getSource("aelis.location")).toBeDefined()
expect(spy).toHaveBeenCalled()
spy.mockRestore()
})
test("throws AggregateError when all providers fail", async () => {
const manager = new UserSessionManager({
providers: [
async () => {
throw new Error("first failed")
},
async () => {
throw new Error("second failed")
},
],
})
await expect(manager.getOrCreate("user-1")).rejects.toBeInstanceOf(AggregateError)
})
test("concurrent getOrCreate for same user returns same session", async () => {
let callCount = 0
const manager = new UserSessionManager({
providers: [
async () => {
callCount++
// Simulate async work to widen the race window
await new Promise((resolve) => setTimeout(resolve, 10))
return new LocationSource()
},
],
})
const [session1, session2] = await Promise.all([
manager.getOrCreate("user-1"),
manager.getOrCreate("user-1"),
])
expect(session1).toBe(session2)
expect(callCount).toBe(1)
})
test("remove during in-flight getOrCreate prevents session from being stored", async () => {
let resolveProvider: () => void
const providerGate = new Promise<void>((r) => {
resolveProvider = r
})
const manager = new UserSessionManager({
providers: [
async () => {
await providerGate
return new LocationSource()
},
],
})
const sessionPromise = manager.getOrCreate("user-1")
// remove() while provider is still resolving
manager.remove("user-1")
// Let the provider finish
resolveProvider!()
await expect(sessionPromise).rejects.toThrow("removed during creation")
// A fresh getOrCreate should produce a new session, not the cancelled one
const freshSession = await manager.getOrCreate("user-1")
expect(freshSession).toBeDefined()
expect(freshSession.engine).toBeDefined()
})
})

View File

@@ -1,5 +1,3 @@
import type { FeedSource } from "@aelis/core"
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
@@ -12,7 +10,6 @@ export interface UserSessionManagerConfig {
export class UserSessionManager {
private sessions = new Map<string, UserSession>()
private pending = new Map<string, Promise<UserSession>>()
private readonly providers: FeedSourceProviderInput[]
private readonly feedEnhancer: FeedEnhancer | null
@@ -21,28 +18,16 @@ export class UserSessionManager {
this.feedEnhancer = config.feedEnhancer ?? null
}
async getOrCreate(userId: string): Promise<UserSession> {
const existing = this.sessions.get(userId)
if (existing) return existing
const inflight = this.pending.get(userId)
if (inflight) return inflight
const promise = this.createSession(userId)
this.pending.set(userId, promise)
try {
const session = await promise
// If remove() was called while we were awaiting, it clears the
// pending entry. Detect that and destroy the session immediately.
if (!this.pending.has(userId)) {
session.destroy()
throw new Error(`Session for user ${userId} was removed during creation`)
}
getOrCreate(userId: string): UserSession {
let session = this.sessions.get(userId)
if (!session) {
const sources = this.providers.map((p) =>
typeof p === "function" ? p(userId) : p.feedSourceForUser(userId),
)
session = new UserSession(sources, this.feedEnhancer)
this.sessions.set(userId, session)
return session
} finally {
this.pending.delete(userId)
}
return session
}
remove(userId: string): void {
@@ -51,36 +36,5 @@ export class UserSessionManager {
session.destroy()
this.sessions.delete(userId)
}
// Cancel any in-flight creation so getOrCreate won't store the session
this.pending.delete(userId)
}
private async createSession(userId: string): Promise<UserSession> {
const results = await Promise.allSettled(
this.providers.map((p) =>
typeof p === "function" ? p(userId) : p.feedSourceForUser(userId),
),
)
const sources: FeedSource[] = []
const errors: unknown[] = []
for (const result of results) {
if (result.status === "fulfilled") {
sources.push(result.value)
} else {
errors.push(result.reason)
}
}
if (sources.length === 0 && errors.length > 0) {
throw new AggregateError(errors, "All feed source providers failed")
}
for (const error of errors) {
console.error("[UserSessionManager] Feed source provider failed:", error)
}
return new UserSession(sources, this.feedEnhancer)
}
}

View File

@@ -76,7 +76,6 @@ describe("UserSession.feed", () => {
const items: FeedItem[] = [
{
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
@@ -94,7 +93,6 @@ describe("UserSession.feed", () => {
const items: FeedItem[] = [
{
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
@@ -115,7 +113,6 @@ describe("UserSession.feed", () => {
const items: FeedItem[] = [
{
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
@@ -142,7 +139,6 @@ describe("UserSession.feed", () => {
let currentItems: FeedItem[] = [
{
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { version: 1 },
@@ -173,7 +169,6 @@ describe("UserSession.feed", () => {
currentItems = [
{
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-02T00:00:00.000Z"),
data: { version: 2 },
@@ -195,7 +190,6 @@ describe("UserSession.feed", () => {
const items: FeedItem[] = [
{
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },

View File

@@ -13,7 +13,7 @@ export class TflSourceProvider implements FeedSourceProvider {
this.options = options
}
async feedSourceForUser(_userId: string): Promise<TflSource> {
feedSourceForUser(_userId: string): TflSource {
return new TflSource(this.options)
}
}

View File

@@ -9,7 +9,7 @@ export class WeatherSourceProvider implements FeedSourceProvider {
this.options = options
}
async feedSourceForUser(_userId: string): Promise<WeatherSource> {
feedSourceForUser(_userId: string): WeatherSource {
return new WeatherSource(this.options)
}
}

View File

@@ -18,11 +18,9 @@
"@expo-google-fonts/inter": "^0.4.2",
"@expo-google-fonts/source-serif-4": "^0.4.1",
"@expo/vector-icons": "^15.0.3",
"@json-render/react-native": "^0.13.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.21",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-dev-client": "~6.0.20",
@@ -47,8 +45,7 @@
"react-native-svg": "15.12.1",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1",
"twrnc": "^4.16.0",
"zod": "^4.3.6"
"twrnc": "^4.16.0"
},
"devDependencies": {
"@types/react": "~19.1.0",

View File

@@ -1,6 +0,0 @@
import { ApiRequestMiddleware } from "./client"
export const authMiddleware: ApiRequestMiddleware = (_url, init) => {
// TODO: placeholder auth middleware
return init
}

View File

@@ -1,39 +0,0 @@
import { createContext, useContext } from "react"
export type ApiRequestMiddleware = (
url: Parameters<typeof fetch>[0],
init: RequestInit,
) => RequestInit
export class ApiClient {
private readonly baseUrl: string
private readonly middlewares: readonly ApiRequestMiddleware[]
static noop = new ApiClient({ baseUrl: "" })
constructor({
baseUrl,
middlewares = [],
}: {
baseUrl: string
middlewares?: ApiRequestMiddleware[]
}) {
this.baseUrl = baseUrl
this.middlewares = middlewares
}
async request<T>(...[url, init = {}]: Parameters<typeof fetch>): Promise<[Response, T]> {
const finalInit = this.middlewares.reduce(
(prevInit, middleware) => middleware(url, prevInit),
init,
)
return fetch(this.baseUrl ? new URL(url.toString(), this.baseUrl) : url, finalInit).then((res) =>
Promise.all([Promise.resolve(res), res.json()]),
)
}
}
export const ApiClientContext = createContext(ApiClient.noop)
export function useApiClient() {
return useContext(ApiClientContext)
}

View File

@@ -0,0 +1,36 @@
import { Tabs } from "expo-router"
import React from "react"
import { HapticTab } from "@/components/haptic-tab"
import { IconSymbol } from "@/components/ui/icon-symbol"
import { Colors } from "@/constants/theme"
import { useColorScheme } from "@/hooks/use-color-scheme"
export default function TabLayout() {
const colorScheme = useColorScheme()
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint,
headerShown: false,
tabBarButton: HapticTab,
}}
>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
}}
/>
<Tabs.Screen
name="explore"
options={{
title: "Explore",
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
}}
/>
</Tabs>
)
}

View File

@@ -0,0 +1,114 @@
import { Image } from "expo-image"
import { Platform, StyleSheet } from "react-native"
import { ExternalLink } from "@/components/external-link"
import ParallaxScrollView from "@/components/parallax-scroll-view"
import { ThemedText } from "@/components/themed-text"
import { ThemedView } from "@/components/themed-view"
import { Collapsible } from "@/components/ui/collapsible"
import { IconSymbol } from "@/components/ui/icon-symbol"
import { Fonts } from "@/constants/theme"
export default function TabTwoScreen() {
return (
<ParallaxScrollView
headerBackgroundColor={{ light: "#D0D0D0", dark: "#353636" }}
headerImage={
<IconSymbol
size={310}
color="#808080"
name="chevron.left.forwardslash.chevron.right"
style={styles.headerImage}
/>
}
>
<ThemedView style={styles.titleContainer}>
<ThemedText
type="title"
style={{
fontFamily: Fonts.rounded,
}}
>
Explore
</ThemedText>
</ThemedView>
<ThemedText>This app includes example code to help you get started.</ThemedText>
<Collapsible title="File-based routing">
<ThemedText>
This app has two screens:{" "}
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{" "}
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
</ThemedText>
<ThemedText>
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{" "}
sets up the tab navigator.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/router/introduction">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Android, iOS, and web support">
<ThemedText>
You can open this project on Android, iOS, and the web. To open the web version, press{" "}
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
</ThemedText>
</Collapsible>
<Collapsible title="Images">
<ThemedText>
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{" "}
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
different screen densities
</ThemedText>
<Image
source={require("@assets/images/react-logo.png")}
style={{ width: 100, height: 100, alignSelf: "center" }}
/>
<ExternalLink href="https://reactnative.dev/docs/images">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Light and dark mode components">
<ThemedText>
This template has light and dark mode support. The{" "}
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
what the user&apos;s current color scheme is, and so you can adjust UI colors accordingly.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Animations">
<ThemedText>
This template includes an example of an animated component. The{" "}
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
the powerful{" "}
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
react-native-reanimated
</ThemedText>{" "}
library to create a waving hand animation.
</ThemedText>
{Platform.select({
ios: (
<ThemedText>
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{" "}
component provides a parallax effect for the header image.
</ThemedText>
),
})}
</Collapsible>
</ParallaxScrollView>
)
}
const styles = StyleSheet.create({
headerImage: {
color: "#808080",
bottom: -90,
left: -35,
position: "absolute",
},
titleContainer: {
flexDirection: "row",
gap: 8,
},
})

View File

@@ -0,0 +1,96 @@
import { Image } from "expo-image"
import { Link } from "expo-router"
import { Platform, StyleSheet } from "react-native"
import { HelloWave } from "@/components/hello-wave"
import ParallaxScrollView from "@/components/parallax-scroll-view"
import { ThemedText } from "@/components/themed-text"
import { ThemedView } from "@/components/themed-view"
export default function HomeScreen() {
return (
<ParallaxScrollView
headerBackgroundColor={{ light: "#A1CEDC", dark: "#1D3D47" }}
headerImage={
<Image source={require("@assets/images/partial-react-logo.png")} style={styles.reactLogo} />
}
>
<ThemedView style={styles.titleContainer}>
<ThemedText type="title">Welcome!</ThemedText>
<HelloWave />
</ThemedView>
<ThemedView style={styles.stepContainer}>
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
<ThemedText>
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
Press{" "}
<ThemedText type="defaultSemiBold">
{Platform.select({
ios: "cmd + d",
android: "cmd + m",
web: "F12",
})}
</ThemedText>{" "}
to open developer tools.
</ThemedText>
</ThemedView>
<ThemedView style={styles.stepContainer}>
<Link href="/modal">
<Link.Trigger>
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
</Link.Trigger>
<Link.Preview />
<Link.Menu>
<Link.MenuAction title="Action" icon="cube" onPress={() => alert("Action pressed")} />
<Link.MenuAction
title="Share"
icon="square.and.arrow.up"
onPress={() => alert("Share pressed")}
/>
<Link.Menu title="More" icon="ellipsis">
<Link.MenuAction
title="Delete"
icon="trash"
destructive
onPress={() => alert("Delete pressed")}
/>
</Link.Menu>
</Link.Menu>
</Link>
<ThemedText>
{`Tap the Explore tab to learn more about what's included in this starter app.`}
</ThemedText>
</ThemedView>
<ThemedView style={styles.stepContainer}>
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
<ThemedText>
{`When you're ready, run `}
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{" "}
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{" "}
<ThemedText type="defaultSemiBold">app</ThemedText> to{" "}
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
</ThemedText>
</ThemedView>
</ParallaxScrollView>
)
}
const styles = StyleSheet.create({
titleContainer: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
stepContainer: {
gap: 8,
marginBottom: 8,
},
reactLogo: {
height: 178,
width: 290,
bottom: 0,
left: 0,
position: "absolute",
},
})

View File

@@ -1,65 +1,23 @@
import "react-native-reanimated"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { DarkTheme, DefaultTheme, ThemeProvider } from "@react-navigation/native"
import { Stack } from "expo-router"
import { StatusBar } from "expo-status-bar"
import React from "react"
import { useColorScheme } from "react-native"
import tw, { useDeviceContext } from "twrnc"
import "react-native-reanimated"
import { useColorScheme } from "@/hooks/use-color-scheme"
import { authMiddleware } from "@/api/auth-middleware"
import { ApiClient, ApiClientContext } from "@/api/client"
const queryClient = new QueryClient()
const apiClient = new ApiClient({
baseUrl: process.env.EXPO_PUBLIC_API_BASE_URL ?? "",
middlewares: [authMiddleware],
})
export const unstable_settings = {
anchor: "(tabs)",
}
export default function RootLayout() {
useDeviceContext(tw)
const colorScheme = useColorScheme()
const headerBg = colorScheme === "dark" ? "#1c1917" : "#f5f5f4"
const headerTint = colorScheme === "dark" ? "#e7e5e4" : "#1c1917"
return (
<ContextProvider>
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: headerBg },
}}
>
<Stack.Screen
name="components/index"
options={{
headerShown: true,
title: "Components",
headerStyle: { backgroundColor: headerBg },
headerTintColor: headerTint,
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="components/[name]"
options={{
headerShown: true,
title: "",
headerStyle: { backgroundColor: headerBg },
headerTintColor: headerTint,
headerShadowVisible: false,
}}
/>
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: "modal", title: "Modal" }} />
</Stack>
<StatusBar style="auto" />
</ContextProvider>
)
}
function ContextProvider({ children }: React.PropsWithChildren) {
return (
<QueryClientProvider client={queryClient}>
<ApiClientContext value={apiClient}>{children}</ApiClientContext>
</QueryClientProvider>
</ThemeProvider>
)
}

View File

@@ -1,48 +0,0 @@
import { useLocalSearchParams, useNavigation } from "expo-router"
import { useEffect } from "react"
import { ScrollView, View } from "react-native"
import tw from "twrnc"
import { buttonShowcase } from "@/components/ui/button.showcase"
import { feedCardShowcase } from "@/components/ui/feed-card.showcase"
import { monospaceTextShowcase } from "@/components/ui/monospace-text.showcase"
import { sansSerifTextShowcase } from "@/components/ui/sans-serif-text.showcase"
import { serifTextShowcase } from "@/components/ui/serif-text.showcase"
import { type Showcase } from "@/components/showcase"
import { SansSerifText } from "@/components/ui/sans-serif-text"
const showcases: Record<string, Showcase> = {
button: buttonShowcase,
"feed-card": feedCardShowcase,
"serif-text": serifTextShowcase,
"sans-serif-text": sansSerifTextShowcase,
"monospace-text": monospaceTextShowcase,
}
export default function ComponentDetailScreen() {
const { name } = useLocalSearchParams<{ name: string }>()
const navigation = useNavigation()
const showcase = showcases[name]
useEffect(() => {
if (showcase) {
navigation.setOptions({ title: showcase.title })
}
}, [navigation, showcase])
if (!showcase) {
return (
<View style={tw`bg-stone-100 dark:bg-stone-900 flex-1 items-center justify-center`}>
<SansSerifText>Component not found</SansSerifText>
</View>
)
}
const ShowcaseComponent = showcase.component
return (
<ScrollView style={tw`bg-stone-100 dark:bg-stone-900 flex-1`} contentContainerStyle={tw`px-5 pb-10 pt-4 gap-6`}>
<ShowcaseComponent />
</ScrollView>
)
}

View File

@@ -1,37 +0,0 @@
import { Link } from "expo-router"
import { FlatList, Pressable, View } from "react-native"
import tw from "twrnc"
import { SansSerifText } from "@/components/ui/sans-serif-text"
const components = [
{ name: "button", label: "Button" },
{ name: "feed-card", label: "FeedCard" },
{ name: "serif-text", label: "SerifText" },
{ name: "sans-serif-text", label: "SansSerifText" },
{ name: "monospace-text", label: "MonospaceText" },
] as const
export default function ComponentsScreen() {
return (
<View style={tw`flex-1`}>
<View style={tw`mx-4 mt-4 rounded-xl border border-stone-200 dark:border-stone-800 overflow-hidden`}>
<FlatList
data={components}
keyExtractor={(item) => item.name}
scrollEnabled={false}
ItemSeparatorComponent={() => (
<View style={tw`border-b border-stone-200 dark:border-stone-800`} />
)}
renderItem={({ item }) => (
<Link href={`/components/${item.name}`} asChild>
<Pressable style={tw`px-4 py-3`}>
<SansSerifText style={tw`text-base`}>{item.label}</SansSerifText>
</Pressable>
</Link>
)}
/>
</View>
</View>
)
}

View File

@@ -1,28 +0,0 @@
import { Link } from "expo-router"
import { Pressable } from "react-native"
import { SafeAreaView } from "react-native-safe-area-context"
import tw from "twrnc"
import { Button } from "@/components/ui/button"
import { FeedCard } from "@/components/ui/feed-card"
import { MonospaceText } from "@/components/ui/monospace-text"
import { SansSerifText } from "@/components/ui/sans-serif-text"
import { SerifText } from "@/components/ui/serif-text"
export default function HomeScreen() {
return (
<SafeAreaView style={tw`bg-stone-100 dark:bg-stone-900 flex-1 px-5 pt-6 gap-4`}>
<FeedCard>
<SerifText style={tw`text-4xl`}>Hello world asdsadsa</SerifText>
<SansSerifText style={tw`text-4xl font-bold`}>Hello world</SansSerifText>
<MonospaceText style={tw`text-4xl`}>asdjsakljdl</MonospaceText>
<Button style={tw`self-start`} label="Test" />
</FeedCard>
<Link href="/components" asChild>
<Pressable>
<SansSerifText style={tw`text-teal-600`}>View component library</SansSerifText>
</Pressable>
</Link>
</SafeAreaView>
)
}

View File

@@ -0,0 +1,29 @@
import { Link } from "expo-router"
import { StyleSheet } from "react-native"
import { ThemedText } from "@/components/themed-text"
import { ThemedView } from "@/components/themed-view"
export default function ModalScreen() {
return (
<ThemedView style={styles.container}>
<ThemedText type="title">This is a modal</ThemedText>
<Link href="/" dismissTo style={styles.link}>
<ThemedText type="link">Go to home screen</ThemedText>
</Link>
</ThemedView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: 20,
},
link: {
marginTop: 15,
paddingVertical: 15,
},
})

View File

@@ -0,0 +1,25 @@
import { Href, Link } from "expo-router"
import { openBrowserAsync, WebBrowserPresentationStyle } from "expo-web-browser"
import { type ComponentProps } from "react"
type Props = Omit<ComponentProps<typeof Link>, "href"> & { href: Href & string }
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (process.env.EXPO_OS !== "web") {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault()
// Open the link in an in-app browser.
await openBrowserAsync(href, {
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
})
}
}}
/>
)
}

View File

@@ -0,0 +1,18 @@
import { BottomTabBarButtonProps } from "@react-navigation/bottom-tabs"
import { PlatformPressable } from "@react-navigation/elements"
import * as Haptics from "expo-haptics"
export function HapticTab(props: BottomTabBarButtonProps) {
return (
<PlatformPressable
{...props}
onPressIn={(ev) => {
if (process.env.EXPO_OS === "ios") {
// Add a soft haptic feedback when pressing down on the tabs.
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
}
props.onPressIn?.(ev)
}}
/>
)
}

View File

@@ -0,0 +1,20 @@
import Animated from "react-native-reanimated"
export function HelloWave() {
return (
<Animated.Text
style={{
fontSize: 28,
lineHeight: 32,
marginTop: -6,
animationName: {
"50%": { transform: [{ rotate: "25deg" }] },
},
animationIterationCount: 4,
animationDuration: "300ms",
}}
>
👋
</Animated.Text>
)
}

View File

@@ -0,0 +1,82 @@
import type { PropsWithChildren, ReactElement } from "react"
import { StyleSheet } from "react-native"
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollOffset,
} from "react-native-reanimated"
import { ThemedView } from "@/components/themed-view"
import { useColorScheme } from "@/hooks/use-color-scheme"
import { useThemeColor } from "@/hooks/use-theme-color"
const HEADER_HEIGHT = 250
type Props = PropsWithChildren<{
headerImage: ReactElement
headerBackgroundColor: { dark: string; light: string }
}>
export default function ParallaxScrollView({
children,
headerImage,
headerBackgroundColor,
}: Props) {
const backgroundColor = useThemeColor({}, "background")
const colorScheme = useColorScheme() ?? "light"
const scrollRef = useAnimatedRef<Animated.ScrollView>()
const scrollOffset = useScrollOffset(scrollRef)
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
),
},
{
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
},
],
}
})
return (
<Animated.ScrollView
ref={scrollRef}
style={{ backgroundColor, flex: 1 }}
scrollEventThrottle={16}
>
<Animated.View
style={[
styles.header,
{ backgroundColor: headerBackgroundColor[colorScheme] },
headerAnimatedStyle,
]}
>
{headerImage}
</Animated.View>
<ThemedView style={styles.content}>{children}</ThemedView>
</Animated.ScrollView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: HEADER_HEIGHT,
overflow: "hidden",
},
content: {
flex: 1,
padding: 32,
gap: 16,
overflow: "hidden",
},
})

View File

@@ -1,18 +0,0 @@
import { View } from "react-native"
import tw from "twrnc"
import { SansSerifText } from "./ui/sans-serif-text"
export type Showcase = {
title: string
component: React.ComponentType
}
export function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<View style={tw`gap-3`}>
<SansSerifText style={tw`text-sm text-stone-500 dark:text-stone-400`}>{title}</SansSerifText>
{children}
</View>
)
}

View File

@@ -0,0 +1,60 @@
import { StyleSheet, Text, type TextProps } from "react-native"
import { useThemeColor } from "@/hooks/use-theme-color"
export type ThemedTextProps = TextProps & {
lightColor?: string
darkColor?: string
type?: "default" | "title" | "defaultSemiBold" | "subtitle" | "link"
}
export function ThemedText({
style,
lightColor,
darkColor,
type = "default",
...rest
}: ThemedTextProps) {
const color = useThemeColor({ light: lightColor, dark: darkColor }, "text")
return (
<Text
style={[
{ color },
type === "default" ? styles.default : undefined,
type === "title" ? styles.title : undefined,
type === "defaultSemiBold" ? styles.defaultSemiBold : undefined,
type === "subtitle" ? styles.subtitle : undefined,
type === "link" ? styles.link : undefined,
style,
]}
{...rest}
/>
)
}
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: "600",
},
title: {
fontSize: 32,
fontWeight: "bold",
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: "bold",
},
link: {
lineHeight: 30,
fontSize: 16,
color: "#0a7ea4",
},
})

View File

@@ -0,0 +1,14 @@
import { View, type ViewProps } from "react-native"
import { useThemeColor } from "@/hooks/use-theme-color"
export type ThemedViewProps = ViewProps & {
lightColor?: string
darkColor?: string
}
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, "background")
return <View style={[{ backgroundColor }, style]} {...otherProps} />
}

View File

@@ -1,42 +0,0 @@
import { View } from "react-native"
import tw from "twrnc"
import { Button } from "./button"
import { type Showcase, Section } from "../showcase"
function ButtonShowcase() {
return (
<View style={tw`gap-6`}>
<Section title="Default">
<Button style={tw`self-start`} label="Press me" />
</Section>
<Section title="Leading icon">
<Button
style={tw`self-start`}
label="Add item"
leadingIcon={<Button.Icon name="plus" />}
/>
</Section>
<Section title="Trailing icon">
<Button
style={tw`self-start`}
label="Next"
trailingIcon={<Button.Icon name="arrow-right" />}
/>
</Section>
<Section title="Both icons">
<Button
style={tw`self-start`}
label="Download"
leadingIcon={<Button.Icon name="download" />}
trailingIcon={<Button.Icon name="chevron-down" />}
/>
</Section>
</View>
)
}
export const buttonShowcase: Showcase = {
title: "Button",
component: ButtonShowcase,
}

View File

@@ -1,43 +0,0 @@
import Feather from "@expo/vector-icons/Feather"
import { type PressableProps, Pressable, View } from "react-native"
import tw from "twrnc"
import { SansSerifText } from "./sans-serif-text"
type FeatherIconName = React.ComponentProps<typeof Feather>["name"]
type ButtonIconProps = {
name: FeatherIconName
}
function ButtonIcon({ name }: ButtonIconProps) {
return <Feather name={name} size={18} color={tw.color("text-stone-100 dark:text-stone-200")} />
}
type ButtonProps = Omit<PressableProps, "children"> & {
label: string
leadingIcon?: React.ReactNode
trailingIcon?: React.ReactNode
}
export function Button({ style, label, leadingIcon, trailingIcon, ...props }: ButtonProps) {
const hasIcons = leadingIcon != null || trailingIcon != null
const textElement = <SansSerifText style={tw`text-stone-100 dark:text-stone-200 font-medium`}>{label}</SansSerifText>
return (
<Pressable style={[tw`rounded-full bg-teal-600 px-4 py-3 w-fit`, style]} {...props}>
{hasIcons ? (
<View style={tw`flex-row items-center gap-1.5`}>
{leadingIcon}
{textElement}
{trailingIcon}
</View>
) : (
textElement
)}
</Pressable>
)
}
Button.Icon = ButtonIcon

View File

@@ -0,0 +1,46 @@
import { PropsWithChildren, useState } from "react"
import { StyleSheet, TouchableOpacity } from "react-native"
import { ThemedText } from "@/components/themed-text"
import { ThemedView } from "@/components/themed-view"
import { IconSymbol } from "@/components/ui/icon-symbol"
import { Colors } from "@/constants/theme"
import { useColorScheme } from "@/hooks/use-color-scheme"
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false)
const theme = useColorScheme() ?? "light"
return (
<ThemedView>
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}
>
<IconSymbol
name="chevron.right"
size={18}
weight="medium"
color={theme === "light" ? Colors.light.icon : Colors.dark.icon}
style={{ transform: [{ rotate: isOpen ? "90deg" : "0deg" }] }}
/>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>
)
}
const styles = StyleSheet.create({
heading: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
})

View File

@@ -1,32 +0,0 @@
import { View } from "react-native"
import tw from "twrnc"
import { Button } from "./button"
import { FeedCard } from "./feed-card"
import { SansSerifText } from "./sans-serif-text"
import { SerifText } from "./serif-text"
import { type Showcase, Section } from "../showcase"
function FeedCardShowcase() {
return (
<View style={tw`gap-6`}>
<Section title="Default">
<FeedCard style={tw`p-4`}>
<SansSerifText>Card content goes here</SansSerifText>
</FeedCard>
</Section>
<Section title="With mixed content">
<FeedCard style={tw`p-4 gap-2`}>
<SerifText style={tw`text-xl`}>Title</SerifText>
<SansSerifText>Body text inside a feed card.</SansSerifText>
<Button style={tw`self-start mt-2`} label="Action" />
</FeedCard>
</Section>
</View>
)
}
export const feedCardShowcase: Showcase = {
title: "FeedCard",
component: FeedCardShowcase,
}

View File

@@ -1,6 +0,0 @@
import { View, type ViewProps } from "react-native"
import tw from "twrnc"
export function FeedCard({ style, ...props }: ViewProps) {
return <View style={[tw`border border-stone-200 dark:border-stone-800 rounded-lg`, style]} {...props} />
}

View File

@@ -0,0 +1,32 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from "expo-symbols"
import { StyleProp, ViewStyle } from "react-native"
export function IconSymbol({
name,
size = 24,
color,
style,
weight = "regular",
}: {
name: SymbolViewProps["name"]
size?: number
color: string
style?: StyleProp<ViewStyle>
weight?: SymbolWeight
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
)
}

View File

@@ -0,0 +1,41 @@
// Fallback for using MaterialIcons on Android and web.
import MaterialIcons from "@expo/vector-icons/MaterialIcons"
import { SymbolWeight, SymbolViewProps } from "expo-symbols"
import { ComponentProps } from "react"
import { OpaqueColorValue, type StyleProp, type TextStyle } from "react-native"
type IconMapping = Record<SymbolViewProps["name"], ComponentProps<typeof MaterialIcons>["name"]>
type IconSymbolName = keyof typeof MAPPING
/**
* Add your SF Symbols to Material Icons mappings here.
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
*/
const MAPPING = {
"house.fill": "home",
"paperplane.fill": "send",
"chevron.left.forwardslash.chevron.right": "code",
"chevron.right": "chevron-right",
} as IconMapping
/**
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
* This ensures a consistent look across platforms, and optimal resource usage.
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName
size?: number
color: string | OpaqueColorValue
style?: StyleProp<TextStyle>
weight?: SymbolWeight
}) {
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />
}

View File

@@ -1,31 +0,0 @@
import { View } from "react-native"
import tw from "twrnc"
import { MonospaceText } from "./monospace-text"
import { type Showcase, Section } from "../showcase"
function MonospaceTextShowcase() {
return (
<View style={tw`gap-6`}>
<Section title="Sizes">
<View style={tw`gap-2`}>
<MonospaceText style={tw`text-sm`}>Small monospace text</MonospaceText>
<MonospaceText style={tw`text-base`}>Base monospace text</MonospaceText>
<MonospaceText style={tw`text-xl`}>Extra large monospace text</MonospaceText>
<MonospaceText style={tw`text-3xl`}>3XL monospace text</MonospaceText>
</View>
</Section>
<Section title="Code-like usage">
<View style={tw`bg-stone-200 dark:bg-stone-800 rounded-lg p-3`}>
<MonospaceText style={tw`text-sm`}>{"const x = 42;"}</MonospaceText>
<MonospaceText style={tw`text-sm`}>{"console.log(x);"}</MonospaceText>
</View>
</Section>
</View>
)
}
export const monospaceTextShowcase: Showcase = {
title: "MonospaceText",
component: MonospaceTextShowcase,
}

View File

@@ -1,10 +0,0 @@
import { Text, type TextProps } from "react-native"
import tw from "twrnc"
export function MonospaceText({ children, style, ...props }: TextProps) {
return (
<Text style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Menlo" }, style]} {...props}>
{children}
</Text>
)
}

View File

@@ -1,34 +0,0 @@
import { View } from "react-native"
import tw from "twrnc"
import { SansSerifText } from "./sans-serif-text"
import { type Showcase, Section } from "../showcase"
function SansSerifTextShowcase() {
return (
<View style={tw`gap-6`}>
<Section title="Sizes">
<View style={tw`gap-2`}>
<SansSerifText style={tw`text-sm`}>Small sans-serif text</SansSerifText>
<SansSerifText style={tw`text-base`}>Base sans-serif text</SansSerifText>
<SansSerifText style={tw`text-xl`}>Extra large sans-serif text</SansSerifText>
<SansSerifText style={tw`text-3xl`}>3XL sans-serif text</SansSerifText>
</View>
</Section>
<Section title="Weights">
<View style={tw`gap-2`}>
<SansSerifText style={tw`font-light`}>Light weight</SansSerifText>
<SansSerifText style={tw`font-normal`}>Normal weight</SansSerifText>
<SansSerifText style={tw`font-medium`}>Medium weight</SansSerifText>
<SansSerifText style={tw`font-semibold`}>Semibold weight</SansSerifText>
<SansSerifText style={tw`font-bold`}>Bold weight</SansSerifText>
</View>
</Section>
</View>
)
}
export const sansSerifTextShowcase: Showcase = {
title: "SansSerifText",
component: SansSerifTextShowcase,
}

View File

@@ -1,10 +0,0 @@
import { Text, type TextProps } from "react-native"
import tw from "twrnc"
export function SansSerifText({ children, style, ...props }: TextProps) {
return (
<Text style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Inter" }, style]} {...props}>
{children}
</Text>
)
}

View File

@@ -1,25 +0,0 @@
import { View } from "react-native"
import tw from "twrnc"
import { SerifText } from "./serif-text"
import { type Showcase, Section } from "../showcase"
function SerifTextShowcase() {
return (
<View style={tw`gap-6`}>
<Section title="Sizes">
<View style={tw`gap-2`}>
<SerifText style={tw`text-sm`}>Small serif text</SerifText>
<SerifText style={tw`text-base`}>Base serif text</SerifText>
<SerifText style={tw`text-xl`}>Extra large serif text</SerifText>
<SerifText style={tw`text-3xl`}>3XL serif text</SerifText>
</View>
</Section>
</View>
)
}
export const serifTextShowcase: Showcase = {
title: "SerifText",
component: SerifTextShowcase,
}

View File

@@ -1,10 +0,0 @@
import { Text, type TextProps } from "react-native"
import tw from "twrnc"
export function SerifText({ children, style, ...props }: TextProps) {
return (
<Text style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Source Serif 4" }, style]} {...props}>
{children}
</Text>
)
}

View File

@@ -0,0 +1,53 @@
/**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
import { Platform } from "react-native"
const tintColorLight = "#0a7ea4"
const tintColorDark = "#fff"
export const Colors = {
light: {
text: "#11181C",
background: "#fff",
tint: tintColorLight,
icon: "#687076",
tabIconDefault: "#687076",
tabIconSelected: tintColorLight,
},
dark: {
text: "#ECEDEE",
background: "#151718",
tint: tintColorDark,
icon: "#9BA1A6",
tabIconDefault: "#9BA1A6",
tabIconSelected: tintColorDark,
},
}
export const Fonts = Platform.select({
ios: {
/** iOS `UIFontDescriptorSystemDesignDefault` */
sans: "system-ui",
/** iOS `UIFontDescriptorSystemDesignSerif` */
serif: "ui-serif",
/** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: "ui-rounded",
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: "ui-monospace",
},
default: {
sans: "normal",
serif: "serif",
rounded: "normal",
mono: "monospace",
},
web: {
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
serif: "Georgia, 'Times New Roman', serif",
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
},
})

View File

@@ -1,13 +0,0 @@
import { queryOptions } from "@tanstack/react-query"
import { useApiClient } from "@/api/client"
import { FeedItem } from "./types"
export function useFeedQuery() {
const api = useApiClient()
return queryOptions({
queryKey: ["feed"],
queryFn: async () => api.request<{ items: FeedItem[] }>("/feed?render=json-render"),
})
}

View File

@@ -1,5 +0,0 @@
import { Spec } from "@json-render/core"
export interface FeedItem {
ui: Spec
}

View File

@@ -0,0 +1 @@
export { useColorScheme } from "react-native"

View File

@@ -0,0 +1,21 @@
import { useEffect, useState } from "react"
import { useColorScheme as useRNColorScheme } from "react-native"
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false)
useEffect(() => {
setHasHydrated(true)
}, [])
const colorScheme = useRNColorScheme()
if (hasHydrated) {
return colorScheme
}
return "light"
}

View File

@@ -0,0 +1,21 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from "@/constants/theme"
import { useColorScheme } from "@/hooks/use-color-scheme"
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark,
) {
const theme = useColorScheme() ?? "light"
const colorFromProps = props[theme]
if (colorFromProps) {
return colorFromProps
} else {
return Colors[theme][colorName]
}
}

View File

@@ -1,68 +0,0 @@
import { defineCatalog } from "@json-render/core"
import { schema } from "@json-render/react-native/schema"
import { z } from "zod"
export const catalog = defineCatalog(schema, {
components: {
View: {
props: z.object({
style: z.string().nullable(),
}),
slots: ["default"],
description:
"Generic layout container. The style prop accepts a twrnc class string (e.g. 'flex-row gap-2 p-4 items-center').",
example: { style: "flex-row gap-2 p-4" },
},
Button: {
props: z.object({
label: z.string(),
leadingIcon: z.string().nullable(),
trailingIcon: z.string().nullable(),
}),
events: ["press"],
slots: [],
description:
"Pressable button with a label and optional Feather icons. Icon values are Feather icon names (e.g. 'plus', 'arrow-right'). Bind on.press to trigger an action.",
example: { label: "Add item", leadingIcon: "plus", trailingIcon: null },
},
FeedCard: {
props: z.object({
style: z.string().nullable(),
}),
slots: ["default"],
description: "Bordered card container for feed content. The style prop accepts a twrnc class string.",
example: { style: "p-4 gap-2" },
},
SansSerifText: {
props: z.object({
text: z.string(),
style: z.string().nullable(),
}),
slots: [],
description:
"Sans-serif text (Inter font). The style prop accepts a twrnc class string for size, weight, color, etc.",
example: { text: "Hello world", style: "text-base font-medium" },
},
SerifText: {
props: z.object({
text: z.string(),
style: z.string().nullable(),
}),
slots: [],
description:
"Serif text (Source Serif 4 font). The style prop accepts a twrnc class string for size, color, etc.",
example: { text: "Heading", style: "text-xl" },
},
MonospaceText: {
props: z.object({
text: z.string(),
style: z.string().nullable(),
}),
slots: [],
description:
"Monospace text (Menlo font). The style prop accepts a twrnc class string for size, color, etc.",
example: { text: "const x = 42", style: "text-sm" },
},
},
actions: {},
})

View File

@@ -1,2 +0,0 @@
export { catalog } from "./catalog"
export { registry } from "./registry"

View File

@@ -1,39 +0,0 @@
import { defineRegistry } from "@json-render/react-native"
import { View } from "react-native"
import tw from "twrnc"
import { Button } from "@/components/ui/button"
import { FeedCard } from "@/components/ui/feed-card"
import { MonospaceText } from "@/components/ui/monospace-text"
import { SansSerifText } from "@/components/ui/sans-serif-text"
import { SerifText } from "@/components/ui/serif-text"
import { catalog } from "./catalog"
type ButtonIconName = React.ComponentProps<typeof Button.Icon>["name"]
export const { registry } = defineRegistry(catalog, {
components: {
View: ({ props, children }) => <View style={props.style ? tw`${props.style}` : undefined}>{children}</View>,
Button: ({ props, emit }) => (
<Button
label={props.label}
leadingIcon={props.leadingIcon ? <Button.Icon name={props.leadingIcon as ButtonIconName} /> : undefined}
trailingIcon={props.trailingIcon ? <Button.Icon name={props.trailingIcon as ButtonIconName} /> : undefined}
onPress={() => emit("press")}
/>
),
FeedCard: ({ props, children }) => (
<FeedCard style={props.style ? tw`${props.style}` : undefined}>{children}</FeedCard>
),
SansSerifText: ({ props }) => (
<SansSerifText style={props.style ? tw`${props.style}` : undefined}>{props.text}</SansSerifText>
),
SerifText: ({ props }) => (
<SerifText style={props.style ? tw`${props.style}` : undefined}>{props.text}</SerifText>
),
MonospaceText: ({ props }) => (
<MonospaceText style={props.style ? tw`${props.style}` : undefined}>{props.text}</MonospaceText>
),
},
})

View File

@@ -6,7 +6,7 @@
"name": "aelis",
"devDependencies": {
"@json-render/core": "^0.12.1",
"@nym.sh/jrx": "^0.2.0",
"@nym.sh/jrx": "^0.1.0",
"@types/bun": "latest",
"oxfmt": "^0.24.0",
"oxlint": "^1.39.0",
@@ -42,11 +42,9 @@
"@expo-google-fonts/inter": "^0.4.2",
"@expo-google-fonts/source-serif-4": "^0.4.1",
"@expo/vector-icons": "^15.0.3",
"@json-render/react-native": "^0.13.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.21",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-dev-client": "~6.0.20",
@@ -72,7 +70,6 @@
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1",
"twrnc": "^4.16.0",
"zod": "^4.3.6",
},
"devDependencies": {
"@types/react": "~19.1.0",
@@ -111,14 +108,6 @@
"vite-tsconfig-paths": "^5.1.4",
},
},
"packages/aelis-components": {
"name": "@aelis/components",
"version": "0.0.0",
"peerDependencies": {
"@json-render/core": "*",
"@nym.sh/jrx": "*",
},
},
"packages/aelis-core": {
"name": "@aelis/core",
"version": "0.0.0",
@@ -153,7 +142,6 @@
"name": "@aelis/source-caldav",
"version": "0.0.0",
"dependencies": {
"@aelis/components": "workspace:*",
"@aelis/core": "workspace:*",
"ical.js": "^2.1.0",
"tsdav": "^2.1.7",
@@ -179,15 +167,10 @@
"name": "@aelis/source-tfl",
"version": "0.0.0",
"dependencies": {
"@aelis/components": "workspace:*",
"@aelis/core": "workspace:*",
"@aelis/source-location": "workspace:*",
"arktype": "^2.1.0",
},
"peerDependencies": {
"@json-render/core": "*",
"@nym.sh/jrx": "*",
},
},
"packages/aelis-source-weatherkit": {
"name": "@aelis/source-weatherkit",
@@ -204,8 +187,6 @@
"@aelis/backend": ["@aelis/backend@workspace:apps/aelis-backend"],
"@aelis/components": ["@aelis/components@workspace:packages/aelis-components"],
"@aelis/core": ["@aelis/core@workspace:packages/aelis-core"],
"@aelis/data-source-weatherkit": ["@aelis/data-source-weatherkit@workspace:packages/aelis-data-source-weatherkit"],
@@ -676,8 +657,6 @@
"@json-render/core": ["@json-render/core@0.12.1", "", { "dependencies": { "zod": "^4.3.6" } }, "sha512-1tV/481GPHmIRd6lXfWcTaIslQusmDg5lzcSBzWLkSXjF9sjjyOQL090in7uHT4tOMWkdmlEJOW5H9C72PsUEQ=="],
"@json-render/react-native": ["@json-render/react-native@0.13.0", "", { "dependencies": { "@json-render/core": "0.13.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-native": ">=0.71.0", "zod": "^4.0.0" } }, "sha512-uUrK28xPb7LuyYsi9cTnvrnXnVBG0OwU5Up35aaXwcWMLrfqxJ7oWfF97HlDvZIckQtm0VEngAXhHMW97qBEkg=="],
"@mjackson/node-fetch-server": ["@mjackson/node-fetch-server@0.2.0", "", {}, "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng=="],
"@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.6", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g=="],
@@ -698,7 +677,7 @@
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
"@nym.sh/jrx": ["@nym.sh/jrx@0.2.0", "", { "peerDependencies": { "@json-render/core": ">=0.10.0" } }, "sha512-jd7Z1Q6T21366MtSUnwCFiu6Yl1AdNc9s5m6HxeUg265P+0enZCiyyxOuHsFwvpUcSEs/2DVBsqfMptdca44lA=="],
"@nym.sh/jrx": ["@nym.sh/jrx@0.1.0", "", { "peerDependencies": { "@json-render/core": ">=0.10.0" } }, "sha512-mu6fkAP/TI9FuP8A4WMCrcucpUtWF5xBTcETnrjOtvEED9i+7sQKuoOyhJeF6QaSuUkAA/8t3Xx3kYUjcAPFbw=="],
"@oclif/core": ["@oclif/core@4.8.4", "", { "dependencies": { "ansi-escapes": "^4.3.2", "ansis": "^3.17.0", "clean-stack": "^3.0.1", "cli-spinners": "^2.9.2", "debug": "^4.4.3", "ejs": "^3.1.10", "get-package-type": "^0.1.0", "indent-string": "^4.0.0", "is-wsl": "^2.2.0", "lilconfig": "^3.1.3", "minimatch": "^10.2.4", "semver": "^7.7.3", "string-width": "^4.2.3", "supports-color": "^8", "tinyglobby": "^0.2.14", "widest-line": "^3.1.0", "wordwrap": "^1.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-UTAqwXJJyRvLBvosL+1uPZYSpr8lEHgUb/EVGbPXo5WZqUIBHfJ0sR2bkBEsrj00/ar4IegKxx4YK0wn2c8SQg=="],
@@ -1190,10 +1169,6 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
"@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="],
"@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="],
@@ -3482,8 +3457,6 @@
"@jest/transform/write-file-atomic": ["write-file-atomic@4.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" } }, "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg=="],
"@json-render/react-native/@json-render/core": ["@json-render/core@0.13.0", "", { "dependencies": { "zod": "^4.3.6" } }, "sha512-CXmCsc8BHDRq45ScVd+qgvjTbwZHPVpVD05WnTqgDxqfY3LGXu5vxaQRSwYoEodg/DGcZq/4HSj4ipVvrzy3qQ=="],
"@mrleebo/prisma-ast/lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
"@oclif/core/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],

View File

@@ -15,7 +15,7 @@
},
"devDependencies": {
"@json-render/core": "^0.12.1",
"@nym.sh/jrx": "^0.2.0",
"@nym.sh/jrx": "^0.1.0",
"@types/bun": "latest",
"oxfmt": "^0.24.0",
"oxlint": "^1.39.0"

View File

@@ -1,14 +0,0 @@
{
"name": "@aelis/components",
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test ./src"
},
"peerDependencies": {
"@json-render/core": "*",
"@nym.sh/jrx": "*"
}
}

View File

@@ -1,15 +0,0 @@
import type { JrxNode } from "@nym.sh/jrx"
import { jsx } from "@nym.sh/jrx/jsx-runtime"
export type ButtonProps = {
label: string
leadingIcon?: string
trailingIcon?: string
style?: string
children?: JrxNode | JrxNode[]
}
export function Button(props: ButtonProps): JrxNode {
return jsx("Button", props)
}

View File

@@ -1,155 +0,0 @@
/** @jsxImportSource @nym.sh/jrx */
import { render } from "@nym.sh/jrx"
import { describe, expect, test } from "bun:test"
import { Button } from "./button.ts"
import { FeedCard } from "./feed-card.ts"
import { MonospaceText } from "./monospace-text.ts"
import { SansSerifText } from "./sans-serif-text.ts"
import { SerifText } from "./serif-text.ts"
describe("Button", () => {
test("renders with label", () => {
const spec = render(<Button label="Press me" />)
expect(spec.root).toStartWith("button-")
const root = spec.elements[spec.root]!
expect(root.type).toBe("Button")
expect(root.props).toEqual({ label: "Press me" })
})
test("renders with icon props", () => {
const spec = render(<Button label="Add" leadingIcon="plus" trailingIcon="arrow-right" />)
const root = spec.elements[spec.root]!
expect(root.type).toBe("Button")
expect(root.props).toEqual({
label: "Add",
leadingIcon: "plus",
trailingIcon: "arrow-right",
})
})
test("passes style as string prop", () => {
const spec = render(<Button label="Go" style="px-4 py-2" />)
const root = spec.elements[spec.root]!
expect(root.props.style).toBe("px-4 py-2")
})
})
describe("FeedCard", () => {
test("renders as container", () => {
const spec = render(<FeedCard />)
expect(spec.root).toStartWith("feedcard-")
const root = spec.elements[spec.root]!
expect(root.type).toBe("FeedCard")
})
test("renders with a single child", () => {
const spec = render(
<FeedCard>
<SansSerifText content="Only child" />
</FeedCard>,
)
const root = spec.elements[spec.root]!
expect(root.children).toHaveLength(1)
const child = spec.elements[root.children![0]!]!
expect(child.type).toBe("SansSerifText")
expect(child.props).toEqual({ content: "Only child" })
})
test("passes style as string prop", () => {
const spec = render(<FeedCard style="p-4 border rounded-lg" />)
const root = spec.elements[spec.root]!
expect(root.props.style).toBe("p-4 border rounded-lg")
})
})
describe("SansSerifText", () => {
test("renders with content prop", () => {
const spec = render(<SansSerifText content="Hello" />)
expect(spec.root).toStartWith("sansseriftext-")
const root = spec.elements[spec.root]!
expect(root.type).toBe("SansSerifText")
expect(root.props).toEqual({ content: "Hello" })
})
test("passes style as string prop", () => {
const spec = render(<SansSerifText content="Hello" style="text-sm text-stone-500" />)
const root = spec.elements[spec.root]!
expect(root.props.style).toBe("text-sm text-stone-500")
})
})
describe("SerifText", () => {
test("renders with content prop", () => {
const spec = render(<SerifText content="Title" />)
expect(spec.root).toStartWith("seriftext-")
const root = spec.elements[spec.root]!
expect(root.type).toBe("SerifText")
expect(root.props).toEqual({ content: "Title" })
})
test("passes style as string prop", () => {
const spec = render(<SerifText content="Title" style="text-xl" />)
const root = spec.elements[spec.root]!
expect(root.props.style).toBe("text-xl")
})
})
describe("MonospaceText", () => {
test("renders with content prop", () => {
const spec = render(<MonospaceText content="code()" />)
expect(spec.root).toStartWith("monospacetext-")
const root = spec.elements[spec.root]!
expect(root.type).toBe("MonospaceText")
expect(root.props).toEqual({ content: "code()" })
})
test("passes style as string prop", () => {
const spec = render(<MonospaceText content="code()" style="text-xs" />)
const root = spec.elements[spec.root]!
expect(root.props.style).toBe("text-xs")
})
})
describe("composite", () => {
test("FeedCard with nested children", () => {
const spec = render(
<FeedCard>
<SerifText content="Weather" />
<SansSerifText content="Sunny, 22C" />
<Button label="Details" />
</FeedCard>,
)
const root = spec.elements[spec.root]!
expect(root.type).toBe("FeedCard")
expect(root.children).toHaveLength(3)
const childKeys = root.children!
const child0 = spec.elements[childKeys[0]!]!
const child1 = spec.elements[childKeys[1]!]!
const child2 = spec.elements[childKeys[2]!]!
expect(child0.type).toBe("SerifText")
expect(child0.props).toEqual({ content: "Weather" })
expect(child1.type).toBe("SansSerifText")
expect(child1.props).toEqual({ content: "Sunny, 22C" })
expect(child2.type).toBe("Button")
expect(child2.props).toEqual({ label: "Details" })
})
})

View File

@@ -1,12 +0,0 @@
import type { JrxNode } from "@nym.sh/jrx"
import { jsx } from "@nym.sh/jrx/jsx-runtime"
export type FeedCardProps = {
style?: string
children?: JrxNode | JrxNode[]
}
export function FeedCard(props: FeedCardProps): JrxNode {
return jsx("FeedCard", props)
}

View File

@@ -1,14 +0,0 @@
export type { ButtonProps } from "./button.ts"
export { Button } from "./button.ts"
export type { FeedCardProps } from "./feed-card.ts"
export { FeedCard } from "./feed-card.ts"
export type { SansSerifTextProps } from "./sans-serif-text.ts"
export { SansSerifText } from "./sans-serif-text.ts"
export type { SerifTextProps } from "./serif-text.ts"
export { SerifText } from "./serif-text.ts"
export type { MonospaceTextProps } from "./monospace-text.ts"
export { MonospaceText } from "./monospace-text.ts"

View File

@@ -1,13 +0,0 @@
import type { JrxNode } from "@nym.sh/jrx"
import { jsx } from "@nym.sh/jrx/jsx-runtime"
export type MonospaceTextProps = {
content?: string
style?: string
children?: JrxNode | JrxNode[]
}
export function MonospaceText(props: MonospaceTextProps): JrxNode {
return jsx("MonospaceText", props)
}

View File

@@ -1,13 +0,0 @@
import type { JrxNode } from "@nym.sh/jrx"
import { jsx } from "@nym.sh/jrx/jsx-runtime"
export type SansSerifTextProps = {
content?: string
style?: string
children?: JrxNode | JrxNode[]
}
export function SansSerifText(props: SansSerifTextProps): JrxNode {
return jsx("SansSerifText", props)
}

View File

@@ -1,13 +0,0 @@
import type { JrxNode } from "@nym.sh/jrx"
import { jsx } from "@nym.sh/jrx/jsx-runtime"
export type SerifTextProps = {
content?: string
style?: string
children?: JrxNode | JrxNode[]
}
export function SerifText(props: SerifTextProps): JrxNode {
return jsx("SerifText", props)
}

View File

@@ -1,7 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsxImportSource": "@nym.sh/jrx"
},
"include": ["src"]
}

View File

@@ -17,7 +17,6 @@ import type { FeedItem } from "./feed"
* const data = await fetchWeather(location)
* return [{
* id: `weather-${Date.now()}`,
* sourceId: "aelis.weather",
* type: this.type,
* timestamp: context.time,
* data: { temp: data.temperature },

View File

@@ -99,7 +99,6 @@ function createWeatherSource(
return [
{
id: `weather-${Date.now()}`,
sourceId: "weather",
type: "weather",
timestamp: new Date(),
data: {
@@ -131,7 +130,6 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
return [
{
id: "alert-storm",
sourceId: "alert",
type: "alert",
timestamp: new Date(),
data: { message: "Storm warning!" },
@@ -425,7 +423,6 @@ describe("FeedEngine", () => {
return [
{
id: "item-1",
sourceId: "working",
type: "test",
priority: 0.5,
timestamp: new Date(),
@@ -749,7 +746,6 @@ describe("FeedEngine", () => {
return [
{
id: "item-1",
sourceId: "reactive-items",
type: "test",
priority: 0.5,
timestamp: new Date(),
@@ -834,7 +830,6 @@ describe("FeedEngine", () => {
return [
{
id: `item-${fetchCount}`,
sourceId: "counter",
type: "test",
priority: 0.5,
timestamp: new Date(),
@@ -900,7 +895,6 @@ describe("FeedEngine", () => {
return [
{
id: `weather-${Date.now()}`,
sourceId: "weather",
type: "weather",
priority: 0.5,
timestamp: new Date(),

View File

@@ -29,17 +29,11 @@ type WeatherItem = FeedItem<"weather", { temp: number }>
type CalendarItem = FeedItem<"calendar", { title: string }>
function weatherItem(id: string, temp: number): WeatherItem {
return { id, sourceId: "aelis.weather", type: "weather", timestamp: new Date(), data: { temp } }
return { id, type: "weather", timestamp: new Date(), data: { temp } }
}
function calendarItem(id: string, title: string): CalendarItem {
return {
id,
sourceId: "aelis.calendar",
type: "calendar",
timestamp: new Date(),
data: { title },
}
return { id, type: "calendar", timestamp: new Date(), data: { title } }
}
// =============================================================================

View File

@@ -98,7 +98,6 @@ function createWeatherSource(
return [
{
id: `weather-${Date.now()}`,
sourceId: "weather",
type: "weather",
timestamp: new Date(),
data: {
@@ -130,7 +129,6 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
return [
{
id: "alert-storm",
sourceId: "alert",
type: "alert",
timestamp: new Date(),
data: { message: "Storm warning!" },

View File

@@ -6,7 +6,6 @@ describe("FeedItem slots", () => {
test("FeedItem without slots is valid", () => {
const item: FeedItem<"test", { value: number }> = {
id: "test-1",
sourceId: "test-source",
type: "test",
timestamp: new Date(),
data: { value: 42 },
@@ -18,7 +17,6 @@ describe("FeedItem slots", () => {
test("FeedItem with unfilled slots", () => {
const item: FeedItem<"weather", { temp: number }> = {
id: "weather-1",
sourceId: "aelis.weather",
type: "weather",
timestamp: new Date(),
data: { temp: 18 },
@@ -43,7 +41,6 @@ describe("FeedItem slots", () => {
test("FeedItem with filled slots", () => {
const item: FeedItem<"weather", { temp: number }> = {
id: "weather-1",
sourceId: "aelis.weather",
type: "weather",
timestamp: new Date(),
data: { temp: 18 },
@@ -78,7 +75,6 @@ describe("FeedItem slots", () => {
test("FeedItem with empty slots record", () => {
const item: FeedItem<"test", { value: number }> = {
id: "test-1",
sourceId: "test-source",
type: "test",
timestamp: new Date(),
data: { value: 1 },

View File

@@ -48,7 +48,6 @@ export interface Slot {
*
* const item: WeatherItem = {
* id: "weather-123",
* sourceId: "aelis.weatherkit",
* type: "weather",
* timestamp: new Date(),
* data: { temp: 18, condition: "cloudy" },
@@ -68,8 +67,6 @@ export interface FeedItem<
> {
/** Unique identifier */
id: string
/** ID of the FeedSource that produced this item */
sourceId: string
/** Item type, matches the data source type */
type: TType
/** When this item was generated */
@@ -82,12 +79,6 @@ export interface FeedItem<
slots?: Record<string, Slot>
}
/** Takes a FeedItem and returns a JRX node tree for rendering. */
export type FeedItemRenderer<
TType extends string = string,
TData extends Record<string, unknown> = Record<string, unknown>,
> = (item: FeedItem<TType, TData>) => JrxNode
/** A FeedItem with a JRX UI tree attached for client-side rendering. */
export interface RenderedFeedItem<
TType extends string = string,

View File

@@ -7,7 +7,7 @@ export type { ActionDefinition } from "./action"
export { UnknownActionError } from "./action"
// Feed
export type { FeedItem, FeedItemRenderer, FeedItemSignals, RenderedFeedItem, Slot } from "./feed"
export type { FeedItem, FeedItemSignals, RenderedFeedItem, Slot } from "./feed"
export { TimeRelevance } from "./feed"
// Feed Source

View File

@@ -47,8 +47,6 @@ interface LocationData {
const LocationKey: ContextKey<LocationData> = contextKey("aelis.location", "location")
const SOURCE_ID = "aelis.weather"
export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> {
private readonly DEFAULT_HOURLY_LIMIT = 12
private readonly DEFAULT_DAILY_LIMIT = 7
@@ -238,7 +236,6 @@ function createCurrentWeatherFeedItem(
return {
id: `weather-current-${timestamp.getTime()}`,
sourceId: SOURCE_ID,
type: WeatherFeedItemType.Current,
timestamp,
data: {
@@ -273,7 +270,6 @@ function createHourlyWeatherFeedItem(
return {
id: `weather-hourly-${timestamp.getTime()}-${index}`,
sourceId: SOURCE_ID,
type: WeatherFeedItemType.Hourly,
timestamp,
data: {
@@ -308,7 +304,6 @@ function createDailyWeatherFeedItem(
return {
id: `weather-daily-${timestamp.getTime()}-${index}`,
sourceId: SOURCE_ID,
type: WeatherFeedItemType.Daily,
timestamp,
data: {
@@ -336,7 +331,6 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
return {
id: `weather-alert-${alert.id}`,
sourceId: SOURCE_ID,
type: WeatherFeedItemType.Alert,
timestamp,
data: {

View File

@@ -40,7 +40,6 @@ function saturday(hour: number, minute = 0): Date {
function weatherCurrent(id = "w-current"): FeedItem {
return {
id,
sourceId: "aelis.weather",
type: WeatherFeedItemType.Current,
timestamp: new Date(),
data: { temperature: 18, precipitationIntensity: 0 },
@@ -50,7 +49,6 @@ function weatherCurrent(id = "w-current"): FeedItem {
function weatherCurrentRainy(id = "w-current-rain"): FeedItem {
return {
id,
sourceId: "aelis.weather",
type: WeatherFeedItemType.Current,
timestamp: new Date(),
data: { temperature: 12, precipitationIntensity: 2.5 },
@@ -60,7 +58,6 @@ function weatherCurrentRainy(id = "w-current-rain"): FeedItem {
function weatherCurrentExtreme(id = "w-current-extreme"): FeedItem {
return {
id,
sourceId: "aelis.weather",
type: WeatherFeedItemType.Current,
timestamp: new Date(),
data: { temperature: -5, precipitationIntensity: 0 },
@@ -70,7 +67,6 @@ function weatherCurrentExtreme(id = "w-current-extreme"): FeedItem {
function weatherHourly(id = "w-hourly"): FeedItem {
return {
id,
sourceId: "aelis.weather",
type: WeatherFeedItemType.Hourly,
timestamp: new Date(),
data: { forecastTime: new Date(), temperature: 20 },
@@ -80,7 +76,6 @@ function weatherHourly(id = "w-hourly"): FeedItem {
function weatherDaily(id = "w-daily"): FeedItem {
return {
id,
sourceId: "aelis.weather",
type: WeatherFeedItemType.Daily,
timestamp: new Date(),
data: { forecastDate: new Date() },
@@ -90,7 +85,6 @@ function weatherDaily(id = "w-daily"): FeedItem {
function weatherAlert(id = "w-alert", urgency = 0.9): FeedItem {
return {
id,
sourceId: "aelis.weather",
type: WeatherFeedItemType.Alert,
timestamp: new Date(),
data: { severity: "extreme" },
@@ -105,7 +99,6 @@ function calendarEvent(
): FeedItem {
return {
id,
sourceId: "aelis.google-calendar",
type: CalendarFeedItemType.Event,
timestamp: new Date(),
data: {
@@ -127,7 +120,6 @@ function calendarEvent(
function calendarAllDay(id: string): FeedItem {
return {
id,
sourceId: "aelis.google-calendar",
type: CalendarFeedItemType.AllDay,
timestamp: new Date(),
data: {
@@ -153,7 +145,6 @@ function caldavEvent(
): FeedItem {
return {
id,
sourceId: "aelis.caldav",
type: CalDavFeedItemType.Event,
timestamp: new Date(),
data: {
@@ -179,7 +170,6 @@ function caldavEvent(
function tflAlert(id = "tfl-1", urgency = 0.8): FeedItem {
return {
id,
sourceId: "aelis.tfl",
type: TflFeedItemType.Alert,
timestamp: new Date(),
data: {
@@ -195,7 +185,6 @@ function tflAlert(id = "tfl-1", urgency = 0.8): FeedItem {
function unknownItem(id = "unknown-1"): FeedItem {
return {
id,
sourceId: "unknown",
type: "some-future-type",
timestamp: new Date(),
data: { foo: "bar" },

View File

@@ -9,7 +9,6 @@
"test:live": "bun run scripts/test-live.ts"
},
"dependencies": {
"@aelis/components": "workspace:*",
"@aelis/core": "workspace:*",
"ical.js": "^2.1.0",
"tsdav": "^2.1.7"

View File

@@ -133,7 +133,7 @@ export class CalDavSource implements FeedSource<CalDavFeedItem> {
async fetchItems(context: Context): Promise<CalDavFeedItem[]> {
const now = context.time
const events = await this.fetchEvents(context)
return events.map((event) => createFeedItem(event, now, this.id, this.timeZone))
return events.map((event) => createFeedItem(event, now, this.timeZone))
}
private fetchEvents(context: Context): Promise<CalDavEventData[]> {
@@ -351,15 +351,9 @@ function createEventSlots(): Record<string, Slot> {
}
}
function createFeedItem(
event: CalDavEventData,
now: Date,
sourceId: string,
timeZone?: string,
): CalDavFeedItem {
function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): CalDavFeedItem {
return {
id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
sourceId,
type: CalDavFeedItemType.Event,
timestamp: now,
data: event,

View File

@@ -1,7 +1,6 @@
export { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
export { CalDavSource, type CalDavSourceOptions } from "./caldav-source.ts"
export { parseICalEvents, type ICalTimeRange } from "./ical-parser.ts"
export { renderCalDavFeedItem } from "./renderer.tsx"
export {
AttendeeRole,
AttendeeStatus,

View File

@@ -1,74 +0,0 @@
/** @jsxImportSource @nym.sh/jrx */
import type { FeedItemRenderer } from "@aelis/core"
import { FeedCard, SansSerifText, SerifText } from "@aelis/components"
import type { CalDavEventData } from "./types.ts"
import { CalDavEventStatus } from "./types.ts"
function formatTime(date: Date): string {
const hours = date.getHours()
const minutes = date.getMinutes()
const period = hours >= 12 ? "PM" : "AM"
const h = hours % 12 || 12
const m = minutes.toString().padStart(2, "0")
return `${h}:${m} ${period}`
}
function formatTimeRange(data: CalDavEventData): string {
if (data.isAllDay) {
return "All day"
}
return `${formatTime(data.startDate)} ${formatTime(data.endDate)}`
}
function formatStatus(status: CalDavEventData["status"]): string | null {
if (status === CalDavEventStatus.Cancelled) return "Cancelled"
if (status === CalDavEventStatus.Tentative) return "Tentative"
return null
}
export const renderCalDavFeedItem: FeedItemRenderer<"caldav-event", CalDavEventData> = (item) => {
const { data, slots } = item
const statusLabel = formatStatus(data.status)
const attendeeCount = data.attendees.length
return (
<FeedCard>
{statusLabel ? <SansSerifText content={statusLabel} style="text-xs uppercase" /> : null}
<SerifText content={data.title} style="text-lg" />
<SansSerifText content={formatTimeRange(data)} style="text-sm" />
{data.calendarName ? (
<SansSerifText content={data.calendarName} style="text-sm text-secondary" />
) : null}
{data.location ? (
<SansSerifText content={data.location} style="text-sm text-secondary" />
) : null}
{attendeeCount > 0 ? (
<SansSerifText
content={`${attendeeCount} attendee${attendeeCount === 1 ? "" : "s"}`}
style="text-sm text-secondary"
/>
) : null}
{slots?.insight?.content ? (
<SansSerifText content={slots.insight.content} style="text-sm" />
) : null}
{slots?.preparation?.content ? (
<SansSerifText content={slots.preparation.content} style="text-sm" />
) : null}
{slots?.crossSource?.content ? (
<SansSerifText content={slots.crossSource.content} style="text-sm" />
) : null}
</FeedCard>
)
}

View File

@@ -1,7 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsxImportSource": "@nym.sh/jrx"
},
"include": ["src"]
}

View File

@@ -113,7 +113,7 @@ export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
const now = context.time.getTime()
const lookaheadMs = this.lookaheadHours * 60 * 60 * 1000
return events.map((event) => createFeedItem(event, now, lookaheadMs, this.id))
return events.map((event) => createFeedItem(event, now, lookaheadMs))
}
private async resolveCalendarIds(): Promise<string[]> {
@@ -208,13 +208,11 @@ function createFeedItem(
event: CalendarEventData,
nowMs: number,
lookaheadMs: number,
sourceId: string,
): CalendarFeedItem {
const itemType = event.isAllDay ? CalendarFeedItemType.AllDay : CalendarFeedItemType.Event
return {
id: `calendar-${event.calendarId}-${event.eventId}`,
sourceId,
type: itemType,
timestamp: new Date(nowMs),
data: event,

View File

@@ -10,12 +10,7 @@
},
"dependencies": {
"@aelis/core": "workspace:*",
"@aelis/components": "workspace:*",
"@aelis/source-location": "workspace:*",
"arktype": "^2.1.0"
},
"peerDependencies": {
"@json-render/core": "*",
"@nym.sh/jrx": "*"
}
}

View File

@@ -11,4 +11,3 @@ export {
type TflLineStatus,
type TflSourceOptions,
} from "./types.ts"
export { renderTflAlert } from "./renderer.tsx"

View File

@@ -1,103 +0,0 @@
/** @jsxImportSource @nym.sh/jrx */
import { render } from "@nym.sh/jrx"
import { describe, expect, test } from "bun:test"
import type { TflAlertFeedItem } from "./types.ts"
import { renderTflAlert } from "./renderer.tsx"
function makeItem(overrides: Partial<TflAlertFeedItem["data"]> = {}): TflAlertFeedItem {
return {
id: "tfl-alert-northern-minor-delays",
type: "tfl-alert",
timestamp: new Date("2026-01-15T12:00:00Z"),
data: {
line: "northern",
lineName: "Northern",
severity: "minor-delays",
description: "Minor delays due to signal failure",
closestStationDistance: null,
...overrides,
},
}
}
describe("renderTflAlert", () => {
test("renders a FeedCard with title and description", () => {
const node = renderTflAlert(makeItem())
const spec = render(node)
const root = spec.elements[spec.root]!
expect(root.type).toBe("FeedCard")
expect(root.children!.length).toBeGreaterThanOrEqual(2)
const title = spec.elements[root.children![0]!]!
expect(title.type).toBe("SansSerifText")
expect(title.props.content).toBe("Northern · Minor delays")
const body = spec.elements[root.children![1]!]!
expect(body.type).toBe("SansSerifText")
expect(body.props.content).toBe("Minor delays due to signal failure")
})
test("shows nearest station distance when available", () => {
const node = renderTflAlert(makeItem({ closestStationDistance: 0.35 }))
const spec = render(node)
const root = spec.elements[spec.root]!
expect(root.children).toHaveLength(3)
const caption = spec.elements[root.children![2]!]!
expect(caption.type).toBe("SansSerifText")
expect(caption.props.content).toBe("Nearest station: 350m away")
})
test("formats distance in km when >= 1km", () => {
const node = renderTflAlert(makeItem({ closestStationDistance: 2.456 }))
const spec = render(node)
const root = spec.elements[spec.root]!
const caption = spec.elements[root.children![2]!]!
expect(caption.props.content).toBe("Nearest station: 2.5km away")
})
test("formats near-1km boundary as km not meters", () => {
const node = renderTflAlert(makeItem({ closestStationDistance: 0.9999 }))
const spec = render(node)
const root = spec.elements[spec.root]!
const caption = spec.elements[root.children![2]!]!
expect(caption.props.content).toBe("Nearest station: 1.0km away")
})
test("omits station distance when null", () => {
const node = renderTflAlert(makeItem({ closestStationDistance: null }))
const spec = render(node)
const root = spec.elements[spec.root]!
// Title + body only, no caption (empty fragment doesn't produce a child)
const children = root.children!.filter((key) => {
const el = spec.elements[key]
return el && el.type !== "Fragment"
})
expect(children).toHaveLength(2)
})
test("renders closure severity label", () => {
const node = renderTflAlert(makeItem({ severity: "closure", lineName: "Central" }))
const spec = render(node)
const root = spec.elements[spec.root]!
const title = spec.elements[root.children![0]!]!
expect(title.props.content).toBe("Central · Closed")
})
test("renders major delays severity label", () => {
const node = renderTflAlert(makeItem({ severity: "major-delays", lineName: "Jubilee" }))
const spec = render(node)
const root = spec.elements[spec.root]!
const title = spec.elements[root.children![0]!]!
expect(title.props.content).toBe("Jubilee · Major delays")
})
})

View File

@@ -1,40 +0,0 @@
/** @jsxImportSource @nym.sh/jrx */
import type { FeedItemRenderer } from "@aelis/core"
import { FeedCard, SansSerifText } from "@aelis/components"
import type { TflAlertData } from "./types.ts"
import { TflAlertSeverity } from "./types.ts"
const SEVERITY_LABEL: Record<TflAlertSeverity, string> = {
[TflAlertSeverity.Closure]: "Closed",
[TflAlertSeverity.MajorDelays]: "Major delays",
[TflAlertSeverity.MinorDelays]: "Minor delays",
}
function formatDistance(km: number): string {
const meters = Math.round(km * 1000)
if (meters < 1000) {
return `${meters}m away`
}
return `${(meters / 1000).toFixed(1)}km away`
}
export const renderTflAlert: FeedItemRenderer<"tfl-alert", TflAlertData> = (item) => {
const { lineName, severity, description, closestStationDistance } = item.data
const severityLabel = SEVERITY_LABEL[severity]
return (
<FeedCard>
<SansSerifText content={`${lineName} · ${severityLabel}`} style="text-base font-semibold" />
<SansSerifText content={description} style="text-sm" />
{closestStationDistance !== null ? (
<SansSerifText
content={`Nearest station: ${formatDistance(closestStationDistance)}`}
style="text-xs text-stone-500"
/>
) : null}
</FeedCard>
)
}

View File

@@ -151,7 +151,6 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
return {
id: `tfl-alert-${status.lineId}-${status.severity}`,
sourceId: this.id,
type: TflFeedItemType.Alert,
timestamp: context.time,
data,

View File

@@ -1,7 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsxImportSource": "@nym.sh/jrx"
},
"include": ["src"]
}

View File

@@ -167,9 +167,7 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
const items: WeatherFeedItem[] = []
if (response.currentWeather) {
items.push(
createCurrentWeatherFeedItem(response.currentWeather, timestamp, this.units, this.id),
)
items.push(createCurrentWeatherFeedItem(response.currentWeather, timestamp, this.units))
}
if (response.forecastHourly?.hours) {
@@ -177,7 +175,7 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
for (let i = 0; i < hours.length; i++) {
const hour = hours[i]
if (hour) {
items.push(createHourlyWeatherFeedItem(hour, i, timestamp, this.units, this.id))
items.push(createHourlyWeatherFeedItem(hour, i, timestamp, this.units))
}
}
}
@@ -187,14 +185,14 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
for (let i = 0; i < days.length; i++) {
const day = days[i]
if (day) {
items.push(createDailyWeatherFeedItem(day, i, timestamp, this.units, this.id))
items.push(createDailyWeatherFeedItem(day, i, timestamp, this.units))
}
}
}
if (response.weatherAlerts?.alerts) {
for (const alert of response.weatherAlerts.alerts) {
items.push(createWeatherAlertFeedItem(alert, timestamp, this.id))
items.push(createWeatherAlertFeedItem(alert, timestamp))
}
}
@@ -286,7 +284,6 @@ function createCurrentWeatherFeedItem(
current: CurrentWeather,
timestamp: Date,
units: Units,
sourceId: string,
): WeatherFeedItem {
const signals: FeedItemSignals = {
urgency: adjustUrgencyForCondition(BASE_URGENCY.current, current.conditionCode),
@@ -295,7 +292,6 @@ function createCurrentWeatherFeedItem(
return {
id: `weather-current-${timestamp.getTime()}`,
sourceId,
type: WeatherFeedItemType.Current,
timestamp,
data: {
@@ -328,7 +324,6 @@ function createHourlyWeatherFeedItem(
index: number,
timestamp: Date,
units: Units,
sourceId: string,
): WeatherFeedItem {
const signals: FeedItemSignals = {
urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode),
@@ -337,7 +332,6 @@ function createHourlyWeatherFeedItem(
return {
id: `weather-hourly-${timestamp.getTime()}-${index}`,
sourceId,
type: WeatherFeedItemType.Hourly,
timestamp,
data: {
@@ -364,7 +358,6 @@ function createDailyWeatherFeedItem(
index: number,
timestamp: Date,
units: Units,
sourceId: string,
): WeatherFeedItem {
const signals: FeedItemSignals = {
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
@@ -373,7 +366,6 @@ function createDailyWeatherFeedItem(
return {
id: `weather-daily-${timestamp.getTime()}-${index}`,
sourceId,
type: WeatherFeedItemType.Daily,
timestamp,
data: {
@@ -393,11 +385,7 @@ function createDailyWeatherFeedItem(
}
}
function createWeatherAlertFeedItem(
alert: WeatherAlert,
timestamp: Date,
sourceId: string,
): WeatherFeedItem {
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherFeedItem {
const signals: FeedItemSignals = {
urgency: adjustUrgencyForAlertSeverity(alert.severity),
timeRelevance: timeRelevanceForAlertSeverity(alert.severity),
@@ -405,7 +393,6 @@ function createWeatherAlertFeedItem(
return {
id: `weather-alert-${alert.id}`,
sourceId,
type: WeatherFeedItemType.Alert,
timestamp,
data: {