Compare commits

..

1 Commits

Author SHA1 Message Date
e6ca1763ef refactor: rename aris to aelis
Rename all references across the codebase: package names,
imports, source IDs, directory names, docs, and configs.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-10 19:18:17 +00:00
97 changed files with 987 additions and 2102 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 run: bun install --frozen-lockfile
- name: Run tests - name: Run tests
run: bun run test run: bun test

View File

@@ -61,7 +61,7 @@ export async function getSessionFromHeaders(
} }
/** /**
* Dev/test middleware that injects a fake user and session. * Test-only middleware that injects a fake user and session.
* Pass userId to simulate an authenticated request, or omit to get 401. * Pass userId to simulate an authenticated request, or omit to get 401.
*/ */
export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddleware { export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddleware {
@@ -69,34 +69,8 @@ export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddlewar
if (!userId) { if (!userId) {
return c.json({ error: "Unauthorized" }, 401) return c.json({ error: "Unauthorized" }, 401)
} }
c.set("user", { id: userId } as AuthUser)
const now = new Date() c.set("session", { id: "mock-session" } as AuthSession)
const expiresAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
const user: AuthUser = {
id: "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn",
name: "Dev User",
email: "dev@aelis.local",
emailVerified: true,
image: null,
createdAt: now,
updatedAt: now,
}
const session: AuthSession = {
id: "Wt3FvBpXaQrMhD8sKjE6LcYn0gUz5iRo",
userId: "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn",
token: "Vb9CxNfRm2KwQs7TjPeA5dLhYg0UoZi4",
expiresAt,
ipAddress: "127.0.0.1",
userAgent: "aelis-dev",
createdAt: now,
updatedAt: now,
}
c.set("user", user)
c.set("session", session)
await next() await next()
} }
} }

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 { function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
return { return {
id: "item-1", id: "item-1",
sourceId: "test",
type: "test", type: "test",
timestamp: new Date("2025-01-01T00:00:00Z"), timestamp: new Date("2025-01-01T00:00:00Z"),
data: { value: 42 }, data: { value: 42 },

View File

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

View File

@@ -7,7 +7,6 @@ import { buildPrompt, hasUnfilledSlots } from "./prompt-builder.ts"
function makeItem(overrides: Partial<FeedItem> = {}): FeedItem { function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
return { return {
id: "item-1", id: "item-1",
sourceId: "test",
type: "test", type: "test",
timestamp: new Date("2025-01-01T00:00:00Z"), timestamp: new Date("2025-01-01T00:00:00Z"),
data: { value: 42 }, data: { value: 42 },
@@ -61,9 +60,7 @@ describe("buildPrompt", () => {
expect(parsed.items).toHaveLength(1) 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]!.id).toBe("item-1")
expect((parsed.items as Array<Record<string, unknown>>)[0]!.slots).toEqual({ expect((parsed.items as Array<Record<string, unknown>>)[0]!.slots).toEqual({ insight: "Weather insight" })
insight: "Weather insight",
})
expect((parsed.items as Array<Record<string, unknown>>)[0]!.type).toBeUndefined() expect((parsed.items as Array<Record<string, unknown>>)[0]!.type).toBeUndefined()
expect(parsed.context).toHaveLength(0) 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 user = c.get("user")!
const sessionManager = c.get("sessionManager") const sessionManager = c.get("sessionManager")
const session = sessionManager.getOrCreate(user.id)
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)
}
await session.engine.executeAction("aelis.location", "update-location", { await session.engine.executeAction("aelis.location", "update-location", {
lat: result.lat, lat: result.lat,
lng: result.lng, lng: result.lng,

View File

@@ -2,10 +2,10 @@ import { LocationSource } from "@aelis/source-location"
import { Hono } from "hono" import { Hono } from "hono"
import { registerAuthHandlers } from "./auth/http.ts" import { registerAuthHandlers } from "./auth/http.ts"
import { mockAuthSessionMiddleware, requireSession } from "./auth/session-middleware.ts" import { requireSession } from "./auth/session-middleware.ts"
import { registerFeedHttpHandlers } from "./engine/http.ts"
import { createFeedEnhancer } from "./enhancement/enhance-feed.ts" import { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
import { createLlmClient } from "./enhancement/llm-client.ts" import { createLlmClient } from "./enhancement/llm-client.ts"
import { registerFeedHttpHandlers } from "./feed/http.ts"
import { registerLocationHttpHandlers } from "./location/http.ts" import { registerLocationHttpHandlers } from "./location/http.ts"
import { UserSessionManager } from "./session/index.ts" import { UserSessionManager } from "./session/index.ts"
import { WeatherSourceProvider } from "./weather/provider.ts" import { WeatherSourceProvider } from "./weather/provider.ts"
@@ -26,7 +26,7 @@ function main() {
const sessionManager = new UserSessionManager({ const sessionManager = new UserSessionManager({
providers: [ providers: [
async () => new LocationSource(), () => new LocationSource(),
new WeatherSourceProvider({ new WeatherSourceProvider({
credentials: { credentials: {
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!, privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
@@ -43,16 +43,10 @@ function main() {
app.get("/health", (c) => c.json({ status: "ok" })) app.get("/health", (c) => c.json({ status: "ok" }))
const isDev = process.env.NODE_ENV !== "production" registerAuthHandlers(app)
const authSessionMiddleware = isDev ? mockAuthSessionMiddleware("dev-user") : requireSession
if (!isDev) {
registerAuthHandlers(app)
}
registerFeedHttpHandlers(app, { registerFeedHttpHandlers(app, {
sessionManager, sessionManager,
authSessionMiddleware, authSessionMiddleware: requireSession,
}) })
registerLocationHttpHandlers(app, { sessionManager }) registerLocationHttpHandlers(app, { sessionManager })

View File

@@ -1,9 +1,9 @@
import type { FeedSource } from "@aelis/core" import type { FeedSource } from "@aelis/core"
export interface FeedSourceProvider { 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 export type FeedSourceProviderInput = FeedSourceProvider | FeedSourceProviderFn

View File

@@ -1,7 +1,7 @@
import type { WeatherKitClient, WeatherKitResponse } from "@aelis/source-weatherkit" import type { WeatherKitClient, WeatherKitResponse } from "@aelis/source-weatherkit"
import { LocationSource } from "@aelis/source-location" 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 { WeatherSourceProvider } from "../weather/provider.ts"
import { UserSessionManager } from "./user-session-manager.ts" import { UserSessionManager } from "./user-session-manager.ts"
@@ -11,38 +11,38 @@ const mockWeatherClient: WeatherKitClient = {
} }
describe("UserSessionManager", () => { describe("UserSessionManager", () => {
test("getOrCreate creates session on first call", async () => { test("getOrCreate creates session on first call", () => {
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")
expect(session).toBeDefined() expect(session).toBeDefined()
expect(session.engine).toBeDefined() expect(session.engine).toBeDefined()
}) })
test("getOrCreate returns same session for same user", async () => { test("getOrCreate returns same session for same user", () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] }) const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = await manager.getOrCreate("user-1") const session1 = manager.getOrCreate("user-1")
const session2 = await manager.getOrCreate("user-1") const session2 = manager.getOrCreate("user-1")
expect(session1).toBe(session2) expect(session1).toBe(session2)
}) })
test("getOrCreate returns different sessions for different users", async () => { test("getOrCreate returns different sessions for different users", () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] }) const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = await manager.getOrCreate("user-1") const session1 = manager.getOrCreate("user-1")
const session2 = await manager.getOrCreate("user-2") const session2 = manager.getOrCreate("user-2")
expect(session1).not.toBe(session2) expect(session1).not.toBe(session2)
}) })
test("each user gets independent source instances", async () => { test("each user gets independent source instances", () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] }) const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = await manager.getOrCreate("user-1") const session1 = manager.getOrCreate("user-1")
const session2 = await manager.getOrCreate("user-2") const session2 = manager.getOrCreate("user-2")
const source1 = session1.getSource<LocationSource>("aelis.location") const source1 = session1.getSource<LocationSource>("aelis.location")
const source2 = session2.getSource<LocationSource>("aelis.location") const source2 = session2.getSource<LocationSource>("aelis.location")
@@ -50,58 +50,58 @@ describe("UserSessionManager", () => {
expect(source1).not.toBe(source2) expect(source1).not.toBe(source2)
}) })
test("remove destroys session and allows re-creation", async () => { test("remove destroys session and allows re-creation", () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] }) const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = await manager.getOrCreate("user-1") const session1 = manager.getOrCreate("user-1")
manager.remove("user-1") manager.remove("user-1")
const session2 = await manager.getOrCreate("user-1") const session2 = manager.getOrCreate("user-1")
expect(session1).not.toBe(session2) expect(session1).not.toBe(session2)
}) })
test("remove is no-op for unknown user", () => { 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() expect(() => manager.remove("unknown")).not.toThrow()
}) })
test("accepts function providers", async () => { 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() const result = await session.engine.refresh()
expect(result.errors).toHaveLength(0) expect(result.errors).toHaveLength(0)
}) })
test("accepts object providers", async () => { test("accepts object providers", () => {
const provider = new WeatherSourceProvider({ client: mockWeatherClient }) const provider = new WeatherSourceProvider({ client: mockWeatherClient })
const manager = new UserSessionManager({ 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() expect(session.getSource("aelis.weather")).toBeDefined()
}) })
test("accepts mixed providers", async () => { test("accepts mixed providers", () => {
const provider = new WeatherSourceProvider({ client: mockWeatherClient }) const provider = new WeatherSourceProvider({ client: mockWeatherClient })
const manager = new UserSessionManager({ 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.location")).toBeDefined()
expect(session.getSource("aelis.weather")).toBeDefined() expect(session.getSource("aelis.weather")).toBeDefined()
}) })
test("refresh returns feed result through session", async () => { 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() const result = await session.engine.refresh()
expect(result).toHaveProperty("context") expect(result).toHaveProperty("context")
@@ -111,9 +111,9 @@ describe("UserSessionManager", () => {
}) })
test("location update via executeAction works", async () => { 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", { await session.engine.executeAction("aelis.location", "update-location", {
lat: 51.5074, lat: 51.5074,
lng: -0.1278, lng: -0.1278,
@@ -126,10 +126,10 @@ describe("UserSessionManager", () => {
}) })
test("subscribe receives updates after location push", async () => { 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 callback = mock()
const session = await manager.getOrCreate("user-1") const session = manager.getOrCreate("user-1")
session.engine.subscribe(callback) session.engine.subscribe(callback)
await session.engine.executeAction("aelis.location", "update-location", { await session.engine.executeAction("aelis.location", "update-location", {
@@ -146,16 +146,16 @@ describe("UserSessionManager", () => {
}) })
test("remove stops reactive updates", async () => { 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 callback = mock()
const session = await manager.getOrCreate("user-1") const session = manager.getOrCreate("user-1")
session.engine.subscribe(callback) session.engine.subscribe(callback)
manager.remove("user-1") manager.remove("user-1")
// Create new session and push location — old callback should not fire // 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", { await session2.engine.executeAction("aelis.location", "update-location", {
lat: 51.5074, lat: 51.5074,
lng: -0.1278, lng: -0.1278,
@@ -167,93 +167,4 @@ describe("UserSessionManager", () => {
expect(callback).not.toHaveBeenCalled() 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 { FeedEnhancer } from "../enhancement/enhance-feed.ts"
import type { FeedSourceProviderInput } from "./feed-source-provider.ts" import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
@@ -12,7 +10,6 @@ export interface UserSessionManagerConfig {
export class UserSessionManager { export class UserSessionManager {
private sessions = new Map<string, UserSession>() private sessions = new Map<string, UserSession>()
private pending = new Map<string, Promise<UserSession>>()
private readonly providers: FeedSourceProviderInput[] private readonly providers: FeedSourceProviderInput[]
private readonly feedEnhancer: FeedEnhancer | null private readonly feedEnhancer: FeedEnhancer | null
@@ -21,28 +18,16 @@ export class UserSessionManager {
this.feedEnhancer = config.feedEnhancer ?? null this.feedEnhancer = config.feedEnhancer ?? null
} }
async getOrCreate(userId: string): Promise<UserSession> { getOrCreate(userId: string): UserSession {
const existing = this.sessions.get(userId) let session = this.sessions.get(userId)
if (existing) return existing if (!session) {
const sources = this.providers.map((p) =>
const inflight = this.pending.get(userId) typeof p === "function" ? p(userId) : p.feedSourceForUser(userId),
if (inflight) return inflight )
session = new UserSession(sources, this.feedEnhancer)
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`)
}
this.sessions.set(userId, session) this.sessions.set(userId, session)
return session
} finally {
this.pending.delete(userId)
} }
return session
} }
remove(userId: string): void { remove(userId: string): void {
@@ -51,36 +36,5 @@ export class UserSessionManager {
session.destroy() session.destroy()
this.sessions.delete(userId) 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[] = [ const items: FeedItem[] = [
{ {
id: "item-1", id: "item-1",
sourceId: "test",
type: "test", type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"), timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 }, data: { value: 42 },
@@ -94,7 +93,6 @@ describe("UserSession.feed", () => {
const items: FeedItem[] = [ const items: FeedItem[] = [
{ {
id: "item-1", id: "item-1",
sourceId: "test",
type: "test", type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"), timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 }, data: { value: 42 },
@@ -115,7 +113,6 @@ describe("UserSession.feed", () => {
const items: FeedItem[] = [ const items: FeedItem[] = [
{ {
id: "item-1", id: "item-1",
sourceId: "test",
type: "test", type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"), timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 }, data: { value: 42 },
@@ -142,7 +139,6 @@ describe("UserSession.feed", () => {
let currentItems: FeedItem[] = [ let currentItems: FeedItem[] = [
{ {
id: "item-1", id: "item-1",
sourceId: "test",
type: "test", type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"), timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { version: 1 }, data: { version: 1 },
@@ -173,7 +169,6 @@ describe("UserSession.feed", () => {
currentItems = [ currentItems = [
{ {
id: "item-1", id: "item-1",
sourceId: "test",
type: "test", type: "test",
timestamp: new Date("2025-01-02T00:00:00.000Z"), timestamp: new Date("2025-01-02T00:00:00.000Z"),
data: { version: 2 }, data: { version: 2 },
@@ -195,7 +190,6 @@ describe("UserSession.feed", () => {
const items: FeedItem[] = [ const items: FeedItem[] = [
{ {
id: "item-1", id: "item-1",
sourceId: "test",
type: "test", type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"), timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 }, data: { value: 42 },

View File

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

View File

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

View File

@@ -18,11 +18,9 @@
"@expo-google-fonts/inter": "^0.4.2", "@expo-google-fonts/inter": "^0.4.2",
"@expo-google-fonts/source-serif-4": "^0.4.1", "@expo-google-fonts/source-serif-4": "^0.4.1",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@json-render/react-native": "^0.13.0",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.21",
"expo": "~54.0.33", "expo": "~54.0.33",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-dev-client": "~6.0.20", "expo-dev-client": "~6.0.20",
@@ -47,8 +45,7 @@
"react-native-svg": "15.12.1", "react-native-svg": "15.12.1",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1", "react-native-worklets": "0.5.1",
"twrnc": "^4.16.0", "twrnc": "^4.16.0"
"zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.1.0", "@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 { DarkTheme, DefaultTheme, ThemeProvider } from "@react-navigation/native"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Stack } from "expo-router" import { Stack } from "expo-router"
import { StatusBar } from "expo-status-bar" import { StatusBar } from "expo-status-bar"
import React from "react" import "react-native-reanimated"
import { useColorScheme } from "react-native" import { useColorScheme } from "@/hooks/use-color-scheme"
import tw, { useDeviceContext } from "twrnc"
import { authMiddleware } from "@/api/auth-middleware" export const unstable_settings = {
import { ApiClient, ApiClientContext } from "@/api/client" anchor: "(tabs)",
}
const queryClient = new QueryClient()
const apiClient = new ApiClient({
baseUrl: process.env.EXPO_PUBLIC_API_BASE_URL ?? "",
middlewares: [authMiddleware],
})
export default function RootLayout() { export default function RootLayout() {
useDeviceContext(tw)
const colorScheme = useColorScheme() const colorScheme = useColorScheme()
const headerBg = colorScheme === "dark" ? "#1c1917" : "#f5f5f4"
const headerTint = colorScheme === "dark" ? "#e7e5e4" : "#1c1917"
return ( return (
<ContextProvider> <ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<Stack <Stack>
screenOptions={{ <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
headerShown: false, <Stack.Screen name="modal" options={{ presentation: "modal", title: "Modal" }} />
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,
}}
/>
</Stack> </Stack>
<StatusBar style="auto" /> <StatusBar style="auto" />
</ContextProvider> </ThemeProvider>
)
}
function ContextProvider({ children }: React.PropsWithChildren) {
return (
<QueryClientProvider client={queryClient}>
<ApiClientContext value={apiClient}>{children}</ApiClientContext>
</QueryClientProvider>
) )
} }

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

@@ -5,8 +5,6 @@
"": { "": {
"name": "aelis", "name": "aelis",
"devDependencies": { "devDependencies": {
"@json-render/core": "^0.12.1",
"@nym.sh/jrx": "^0.2.0",
"@types/bun": "latest", "@types/bun": "latest",
"oxfmt": "^0.24.0", "oxfmt": "^0.24.0",
"oxlint": "^1.39.0", "oxlint": "^1.39.0",
@@ -42,11 +40,9 @@
"@expo-google-fonts/inter": "^0.4.2", "@expo-google-fonts/inter": "^0.4.2",
"@expo-google-fonts/source-serif-4": "^0.4.1", "@expo-google-fonts/source-serif-4": "^0.4.1",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@json-render/react-native": "^0.13.0",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.21",
"expo": "~54.0.33", "expo": "~54.0.33",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-dev-client": "~6.0.20", "expo-dev-client": "~6.0.20",
@@ -72,7 +68,6 @@
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1", "react-native-worklets": "0.5.1",
"twrnc": "^4.16.0", "twrnc": "^4.16.0",
"zod": "^4.3.6",
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.1.0", "@types/react": "~19.1.0",
@@ -111,24 +106,12 @@
"vite-tsconfig-paths": "^5.1.4", "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": { "packages/aelis-core": {
"name": "@aelis/core", "name": "@aelis/core",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.1.0", "@standard-schema/spec": "^1.1.0",
}, },
"peerDependencies": {
"@json-render/core": "*",
"@nym.sh/jrx": "*",
},
}, },
"packages/aelis-data-source-weatherkit": { "packages/aelis-data-source-weatherkit": {
"name": "@aelis/data-source-weatherkit", "name": "@aelis/data-source-weatherkit",
@@ -153,7 +136,6 @@
"name": "@aelis/source-caldav", "name": "@aelis/source-caldav",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@aelis/components": "workspace:*",
"@aelis/core": "workspace:*", "@aelis/core": "workspace:*",
"ical.js": "^2.1.0", "ical.js": "^2.1.0",
"tsdav": "^2.1.7", "tsdav": "^2.1.7",
@@ -179,15 +161,10 @@
"name": "@aelis/source-tfl", "name": "@aelis/source-tfl",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@aelis/components": "workspace:*",
"@aelis/core": "workspace:*", "@aelis/core": "workspace:*",
"@aelis/source-location": "workspace:*", "@aelis/source-location": "workspace:*",
"arktype": "^2.1.0", "arktype": "^2.1.0",
}, },
"peerDependencies": {
"@json-render/core": "*",
"@nym.sh/jrx": "*",
},
}, },
"packages/aelis-source-weatherkit": { "packages/aelis-source-weatherkit": {
"name": "@aelis/source-weatherkit", "name": "@aelis/source-weatherkit",
@@ -204,8 +181,6 @@
"@aelis/backend": ["@aelis/backend@workspace:apps/aelis-backend"], "@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/core": ["@aelis/core@workspace:packages/aelis-core"],
"@aelis/data-source-weatherkit": ["@aelis/data-source-weatherkit@workspace:packages/aelis-data-source-weatherkit"], "@aelis/data-source-weatherkit": ["@aelis/data-source-weatherkit@workspace:packages/aelis-data-source-weatherkit"],
@@ -674,10 +649,6 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@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=="], "@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=="], "@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.6", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g=="],
@@ -698,8 +669,6 @@
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], "@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=="],
"@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=="], "@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=="],
"@oclif/plugin-autocomplete": ["@oclif/plugin-autocomplete@3.2.40", "", { "dependencies": { "@oclif/core": "^4", "ansis": "^3.16.0", "debug": "^4.4.1", "ejs": "^3.1.10" } }, "sha512-HCfDuUV3l5F5Wz7SKkaoFb+OMQ5vKul8zvsPNgI0QbZcQuGHmn3svk+392wSfXboyA1gq8kzEmKPAoQK6r6UNw=="], "@oclif/plugin-autocomplete": ["@oclif/plugin-autocomplete@3.2.40", "", { "dependencies": { "@oclif/core": "^4", "ansis": "^3.16.0", "debug": "^4.4.1", "ejs": "^3.1.10" } }, "sha512-HCfDuUV3l5F5Wz7SKkaoFb+OMQ5vKul8zvsPNgI0QbZcQuGHmn3svk+392wSfXboyA1gq8kzEmKPAoQK6r6UNw=="],
@@ -1190,10 +1159,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=="], "@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/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="],
"@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="], "@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="],
@@ -2062,7 +2027,7 @@
"ical.js": ["ical.js@2.2.1", "", {}, "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg=="], "ical.js": ["ical.js@2.2.1", "", {}, "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -3482,8 +3447,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=="], "@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=="], "@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=="], "@oclif/core/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
@@ -3560,8 +3523,6 @@
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"c12/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "c12/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
@@ -3712,6 +3673,8 @@
"mv/rimraf": ["rimraf@2.4.5", "", { "dependencies": { "glob": "^6.0.1" }, "bin": { "rimraf": "./bin.js" } }, "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ=="], "mv/rimraf": ["rimraf@2.4.5", "", { "dependencies": { "glob": "^6.0.1" }, "bin": { "rimraf": "./bin.js" } }, "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ=="],
"mysql2/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"npm-package-arg/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "npm-package-arg/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
@@ -3746,8 +3709,6 @@
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
"react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],

View File

@@ -14,8 +14,6 @@
"format:check": "oxfmt --check ." "format:check": "oxfmt --check ."
}, },
"devDependencies": { "devDependencies": {
"@json-render/core": "^0.12.1",
"@nym.sh/jrx": "^0.2.0",
"@types/bun": "latest", "@types/bun": "latest",
"oxfmt": "^0.24.0", "oxfmt": "^0.24.0",
"oxlint": "^1.39.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

@@ -7,10 +7,6 @@
"scripts": { "scripts": {
"test": "bun test ." "test": "bun test ."
}, },
"peerDependencies": {
"@nym.sh/jrx": "*",
"@json-render/core": "*"
},
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.1.0" "@standard-schema/spec": "^1.1.0"
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,3 @@
import type { JrxNode } from "@nym.sh/jrx"
/** /**
* Source-provided hints for post-processors. * Source-provided hints for post-processors.
* *
@@ -48,7 +46,6 @@ export interface Slot {
* *
* const item: WeatherItem = { * const item: WeatherItem = {
* id: "weather-123", * id: "weather-123",
* sourceId: "aelis.weatherkit",
* type: "weather", * type: "weather",
* timestamp: new Date(), * timestamp: new Date(),
* data: { temp: 18, condition: "cloudy" }, * data: { temp: 18, condition: "cloudy" },
@@ -68,8 +65,6 @@ export interface FeedItem<
> { > {
/** Unique identifier */ /** Unique identifier */
id: string id: string
/** ID of the FeedSource that produced this item */
sourceId: string
/** Item type, matches the data source type */ /** Item type, matches the data source type */
type: TType type: TType
/** When this item was generated */ /** When this item was generated */
@@ -81,18 +76,3 @@ export interface FeedItem<
/** Named slots for LLM-fillable content. Keys are slot names. */ /** Named slots for LLM-fillable content. Keys are slot names. */
slots?: Record<string, Slot> 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,
TData extends Record<string, unknown> = Record<string, unknown>,
> extends FeedItem<TType, TData> {
/** JRX node tree describing how to render this item */
ui: JrxNode
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -534,68 +534,4 @@ describe("computeSignals", () => {
}) })
}) })
describe("CalDavSource feed item slots", () => {
const EXPECTED_SLOT_NAMES = ["insight", "preparation", "crossSource"]
test("timed event has all three slots with null content", async () => {
const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client)
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items).toHaveLength(1)
const item = items[0]!
expect(item.slots).toBeDefined()
expect(Object.keys(item.slots!).sort()).toEqual([...EXPECTED_SLOT_NAMES].sort())
for (const name of EXPECTED_SLOT_NAMES) {
const slot = item.slots![name]!
expect(slot.content).toBeNull()
expect(typeof slot.description).toBe("string")
expect(slot.description.length).toBeGreaterThan(0)
}
})
test("all-day event has all three slots with null content", async () => {
const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client)
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items).toHaveLength(1)
const item = items[0]!
expect(item.data.isAllDay).toBe(true)
expect(item.slots).toBeDefined()
expect(Object.keys(item.slots!).sort()).toEqual([...EXPECTED_SLOT_NAMES].sort())
for (const name of EXPECTED_SLOT_NAMES) {
expect(item.slots![name]!.content).toBeNull()
}
})
test("cancelled event has all three slots with null content", async () => {
const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/cancelled.ics", data: loadFixture("cancelled-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client)
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items).toHaveLength(1)
const item = items[0]!
expect(item.data.status).toBe("cancelled")
expect(item.slots).toBeDefined()
expect(Object.keys(item.slots!).sort()).toEqual([...EXPECTED_SLOT_NAMES].sort())
for (const name of EXPECTED_SLOT_NAMES) {
expect(item.slots![name]!.content).toBeNull()
}
})
})

View File

@@ -1,4 +1,4 @@
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource, Slot } from "@aelis/core" import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aelis/core"
import { Context, TimeRelevance, UnknownActionError } from "@aelis/core" import { Context, TimeRelevance, UnknownActionError } from "@aelis/core"
import { DAVClient } from "tsdav" import { DAVClient } from "tsdav"
@@ -7,9 +7,6 @@ import type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.t
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts" import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
import { parseICalEvents } from "./ical-parser.ts" import { parseICalEvents } from "./ical-parser.ts"
import crossSourcePrompt from "./prompts/cross-source.txt"
import insightPrompt from "./prompts/insight.txt"
import preparationPrompt from "./prompts/preparation.txt"
import { CalDavEventStatus, CalDavFeedItemType } from "./types.ts" import { CalDavEventStatus, CalDavFeedItemType } from "./types.ts"
// -- Source options -- // -- Source options --
@@ -133,7 +130,7 @@ export class CalDavSource implements FeedSource<CalDavFeedItem> {
async fetchItems(context: Context): Promise<CalDavFeedItem[]> { async fetchItems(context: Context): Promise<CalDavFeedItem[]> {
const now = context.time const now = context.time
const events = await this.fetchEvents(context) 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[]> { private fetchEvents(context: Context): Promise<CalDavEventData[]> {
@@ -343,27 +340,12 @@ export function computeSignals(
return { urgency: 0.2, timeRelevance: TimeRelevance.Ambient } return { urgency: 0.2, timeRelevance: TimeRelevance.Ambient }
} }
function createEventSlots(): Record<string, Slot> { function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): CalDavFeedItem {
return {
insight: { description: insightPrompt, content: null },
preparation: { description: preparationPrompt, content: null },
crossSource: { description: crossSourcePrompt, content: null },
}
}
function createFeedItem(
event: CalDavEventData,
now: Date,
sourceId: string,
timeZone?: string,
): CalDavFeedItem {
return { return {
id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`, id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
sourceId,
type: CalDavFeedItemType.Event, type: CalDavFeedItemType.Event,
timestamp: now, timestamp: now,
data: event, data: event,
signals: computeSignals(event, now, timeZone), signals: computeSignals(event, now, timeZone),
slots: createEventSlots(),
} }
} }

View File

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

View File

@@ -1,8 +0,0 @@
If other feed data (weather, transit, nearby events) would disrupt or materially affect this event, state the connection in one sentence. Infer whether the event is indoor/outdoor/virtual from the title and location. Weather is only relevant if it affects getting to the event or the activity itself (e.g., rain for outdoor events, extreme conditions for physical activities). Return null for indoor or virtual events where weather has no impact. Do not fabricate information you don't have — only reference data present in the feed.
Examples:
- "rain expected at 5pm — bring an umbrella for the walk to Tooley Street"
- "Northern line has delays — leave 15 minutes early"
- "your next event is across town — the 40 min gap may not be enough"
- null (indoor guitar class with wind outside — weather doesn't affect the event)
- null

View File

@@ -1,7 +0,0 @@
One sentence of actionable insight the user can't already see from the event title, time, and location. Do not restate event details. Do not fabricate information you don't have. Return null if there's nothing non-obvious to say.
Examples:
- "you have 2 hours free before this starts"
- "all 8 attendees accepted — expect a full room"
- "third time this has been rescheduled"
- null

View File

@@ -1,6 +0,0 @@
A concrete preparation step — something the user should do, bring, or review before this event. Infer only from available event and feed data. Do not restate event details. Do not fabricate information you don't have. Return null if no useful preparation comes to mind.
Examples:
- "different building from your previous meeting — allow travel time"
- "recurring meeting you declined last week — check if you need to attend"
- null

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,4 +0,0 @@
declare module "*.txt" {
const content: string
export default content
}

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

View File

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

View File

@@ -11,4 +11,3 @@ export {
type TflLineStatus, type TflLineStatus,
type TflSourceOptions, type TflSourceOptions,
} from "./types.ts" } 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 { return {
id: `tfl-alert-${status.lineId}-${status.severity}`, id: `tfl-alert-${status.lineId}-${status.severity}`,
sourceId: this.id,
type: TflFeedItemType.Alert, type: TflFeedItemType.Alert,
timestamp: context.time, timestamp: context.time,
data, 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[] = [] const items: WeatherFeedItem[] = []
if (response.currentWeather) { if (response.currentWeather) {
items.push( items.push(createCurrentWeatherFeedItem(response.currentWeather, timestamp, this.units))
createCurrentWeatherFeedItem(response.currentWeather, timestamp, this.units, this.id),
)
} }
if (response.forecastHourly?.hours) { if (response.forecastHourly?.hours) {
@@ -177,7 +175,7 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
for (let i = 0; i < hours.length; i++) { for (let i = 0; i < hours.length; i++) {
const hour = hours[i] const hour = hours[i]
if (hour) { 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++) { for (let i = 0; i < days.length; i++) {
const day = days[i] const day = days[i]
if (day) { if (day) {
items.push(createDailyWeatherFeedItem(day, i, timestamp, this.units, this.id)) items.push(createDailyWeatherFeedItem(day, i, timestamp, this.units))
} }
} }
} }
if (response.weatherAlerts?.alerts) { if (response.weatherAlerts?.alerts) {
for (const alert of 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, current: CurrentWeather,
timestamp: Date, timestamp: Date,
units: Units, units: Units,
sourceId: string,
): WeatherFeedItem { ): WeatherFeedItem {
const signals: FeedItemSignals = { const signals: FeedItemSignals = {
urgency: adjustUrgencyForCondition(BASE_URGENCY.current, current.conditionCode), urgency: adjustUrgencyForCondition(BASE_URGENCY.current, current.conditionCode),
@@ -295,7 +292,6 @@ function createCurrentWeatherFeedItem(
return { return {
id: `weather-current-${timestamp.getTime()}`, id: `weather-current-${timestamp.getTime()}`,
sourceId,
type: WeatherFeedItemType.Current, type: WeatherFeedItemType.Current,
timestamp, timestamp,
data: { data: {
@@ -328,7 +324,6 @@ function createHourlyWeatherFeedItem(
index: number, index: number,
timestamp: Date, timestamp: Date,
units: Units, units: Units,
sourceId: string,
): WeatherFeedItem { ): WeatherFeedItem {
const signals: FeedItemSignals = { const signals: FeedItemSignals = {
urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode), urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode),
@@ -337,7 +332,6 @@ function createHourlyWeatherFeedItem(
return { return {
id: `weather-hourly-${timestamp.getTime()}-${index}`, id: `weather-hourly-${timestamp.getTime()}-${index}`,
sourceId,
type: WeatherFeedItemType.Hourly, type: WeatherFeedItemType.Hourly,
timestamp, timestamp,
data: { data: {
@@ -364,7 +358,6 @@ function createDailyWeatherFeedItem(
index: number, index: number,
timestamp: Date, timestamp: Date,
units: Units, units: Units,
sourceId: string,
): WeatherFeedItem { ): WeatherFeedItem {
const signals: FeedItemSignals = { const signals: FeedItemSignals = {
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode), urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
@@ -373,7 +366,6 @@ function createDailyWeatherFeedItem(
return { return {
id: `weather-daily-${timestamp.getTime()}-${index}`, id: `weather-daily-${timestamp.getTime()}-${index}`,
sourceId,
type: WeatherFeedItemType.Daily, type: WeatherFeedItemType.Daily,
timestamp, timestamp,
data: { data: {
@@ -393,11 +385,7 @@ function createDailyWeatherFeedItem(
} }
} }
function createWeatherAlertFeedItem( function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherFeedItem {
alert: WeatherAlert,
timestamp: Date,
sourceId: string,
): WeatherFeedItem {
const signals: FeedItemSignals = { const signals: FeedItemSignals = {
urgency: adjustUrgencyForAlertSeverity(alert.severity), urgency: adjustUrgencyForAlertSeverity(alert.severity),
timeRelevance: timeRelevanceForAlertSeverity(alert.severity), timeRelevance: timeRelevanceForAlertSeverity(alert.severity),
@@ -405,7 +393,6 @@ function createWeatherAlertFeedItem(
return { return {
id: `weather-alert-${alert.id}`, id: `weather-alert-${alert.id}`,
sourceId,
type: WeatherFeedItemType.Alert, type: WeatherFeedItemType.Alert,
timestamp, timestamp,
data: { data: {