Compare commits

..

23 Commits

Author SHA1 Message Date
56d97a2c01 fix(client): handle empty url in client.request
Co-authored-by: ona-patrol <ona@nym.sh>
2026-03-15 17:07:33 +00:00
6e09e3c7b9 fix(client): rm unused private route declr 2026-03-15 17:06:53 +00:00
924236181b fix(client): allow req middlewares to run on empty init 2026-03-15 16:54:45 +00:00
f0a4c095b9 fix(client): append base url on api client req
Co-authored-by: Ona <no-reply@ona.com>
2026-03-15 16:53:32 +00:00
8e7bce101d feat(client): wire up API client and react-query
Add ApiClient class, auth middleware placeholder, feed query,
and wrap the app in QueryClientProvider.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-15 16:29:10 +00:00
4b824c66ce feat(tfl): add FeedItemRenderer for TfL alerts (#73)
* feat(tfl): add FeedItemRenderer for TfL alerts

Implement renderTflAlert using JRX and @aelis/components.
Upgrade @nym.sh/jrx to 0.2.0 for null child support.

Co-authored-by: Ona <no-reply@ona.com>

* fix(tfl): add jsxImportSource pragma for CI

The CI test runner doesn't use per-package tsconfig.json,
so the pragma is needed alongside the tsconfig setting.

Co-authored-by: Ona <no-reply@ona.com>

* fix(ci): run tests per-package via bun run test

Use 'bun run test' (which runs 'bun run --filter * test')
instead of 'bun test' so each package runs tests from its
own directory. Add jsxImportSource pragma to renderer files
since consumers without a JRX tsconfig also import them.

Co-authored-by: Ona <no-reply@ona.com>

* fix(tfl): handle near-1km boundary in formatDistance

Values like 0.9999km rounded to 1000m and displayed as
'1000m away'. Now converts to meters first and switches
to km format when rounded meters >= 1000.

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-15 00:20:54 +00:00
272fb9b9b3 feat(caldav): add FeedItemRenderer (#74)
Implement renderCalDavFeedItem using JRX JSX to render
CalDAV events as FeedCard components. Bump @nym.sh/jrx
to 0.2.0 for null/undefined child support.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-15 00:19:44 +00:00
5ea24b0a13 feat(core): add sourceId to FeedItem (#72)
Each FeedSource implementation now sets sourceId on items
it produces, allowing consumers to trace items back to
their originating source.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-14 23:51:41 +00:00
bed033652c ci: add docker build workflow for waitlist website (#71)
* ci: add docker build workflow for waitlist website

Builds and pushes to cr.nym.sh on pushes to master
that touch apps/waitlist-website/.

Co-authored-by: Ona <no-reply@ona.com>

* ci: rename image to aelis-waitlist-website

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-14 23:24:07 +00:00
ec083c3c77 feat(client): add Button.Icon subcomponent (#70)
Introduce Button.Icon to enforce consistent icon styling
(size, theme-aware color) instead of hardcoding Feather
props at each call site. Update showcase and json-render
registry to use it.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-14 00:39:59 +00:00
45fa539d3e feat(core): add FeedItemRenderer type (#69)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-14 00:06:24 +00:00
b4ad910a14 feat: add @aelis/components package with JRX definitions (#68)
JRX component wrappers for the aelis-client UI components,
enabling server-side feed item rendering via json-render.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-13 23:57:54 +00:00
d3452dd452 feat(client): add json-render catalog and registry (#67)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-13 23:56:34 +00:00
c78ad25f0d feat(client): add component library and simplify routing (#66)
* feat(client): add component library and simplify routing

Remove tab layout, explore page, modal, and unused template
components. Replace with single-page layout and a dev component
showcase with per-component detail pages.

- Add Button with label prop, leading/trailing icon support
- Add FeedCard, SerifText, SansSerifText, MonospaceText
- Add colocated *.showcase.tsx files for each component
- Use Stack navigator with themed headers

Co-authored-by: Ona <no-reply@ona.com>

* fix(client): render showcase as JSX component

Co-authored-by: Ona <no-reply@ona.com>

* chore(client): remove dead code chain

Remove ThemedText, useThemeColor, useColorScheme hook,
Colors, and Fonts — none referenced by current screens.

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-13 00:23:06 +00:00
e07157eba0 feat(backend): add GET /api/context endpoint (#65)
* feat(backend): add GET /api/context endpoint

Query context values by key with exact/prefix match
support. Default mode tries exact first, falls back
to prefix.

Co-authored-by: Ona <no-reply@ona.com>

* fix(backend): validate context key element types

Reject booleans, nulls, and nested arrays in the key
param. Only string, number, and plain objects with
primitive values are accepted.

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-13 00:17:54 +00:00
3036f4ad3f refactor(backend): rename feed dir to engine (#64)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-12 00:57:32 +00:00
f2c991eebb feat(core): add RenderedFeedItem type with JRX UI support (#63)
Add RenderedFeedItem that extends FeedItem with a ui: JrxNode field
for client-side rendering. Add @nym.sh/jrx and @json-render/core as
peer dependencies on @aelis/core.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-11 23:31:32 +00:00
805e4f2bc6 feat(backend): bypass auth in development (#62)
Use mockAuthSessionMiddleware with a fully populated dev
user when NODE_ENV is not production. Auth handlers are
only registered in production.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-11 00:21:34 +00:00
34ead53e1d feat(caldav): add slot support for feed items (#57)
Adds three LLM-fillable slots to every CalDav feed item:
insight, preparation, and crossSource. Slot prompts are
stored in separate .txt files under src/prompts/ with
few-shot examples to steer the LLM away from restating
event details.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-10 19:36:34 +00:00
863c298bd3 refactor: rename aris to aelis (#59)
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:19:23 +00:00
230116d9f7 fix(waitlist): add delay before email to avoid rate limit (#61)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-08 03:35:58 +00:00
0a08706cf9 feat: init waitlist website (#60)
* feat: init waitlist website

Co-authored-by: Ona <no-reply@ona.com>

* feat[waitlist]: tweak copy

Co-authored-by: Ona <no-reply@ona.com>

* fix[waitlist]: reminify lottie json

Co-authored-by: Ona <no-reply@ona.com>

* feat[waitlist]: seo and preview stuff

* chore[waitlist]: clean up

* build[waitlist]: add fly.io config

* feat(waitlist): add time-of-day greeting and duplicate email message

Co-authored-by: Ona <no-reply@ona.com>

* feat(waitlist): handle duplicate emails and send confirmation

Co-authored-by: Ona <no-reply@ona.com>

* chore: remove stray console.log

Co-authored-by: Ona <no-reply@ona.com>

* feat(waitlist): add privacy policy page

Co-authored-by: Ona <no-reply@ona.com>

* feat(waitlist): add footer with bottom progressive blur

Co-authored-by: Ona <no-reply@ona.com>

* feat(waitlist): add trouble message and improve error handling

Co-authored-by: Ona <no-reply@ona.com>

* fix(waitlist): fix timeOfDay logic, typo, and add audienceId

Co-authored-by: Ona <no-reply@ona.com>

* feat(waitlist): add .ico fallback favicon and style error page

Co-authored-by: Ona <no-reply@ona.com>

* chore(waitlist): add robots.txt, sitemap, clean dockerignore

Co-authored-by: Ona <no-reply@ona.com>

* feat(waitlist): add footer to privacy policy page

Co-authored-by: Ona <no-reply@ona.com>

* fix(waitlist): use segments instead of audienceId

Co-authored-by: Ona <no-reply@ona.com>

* fix[waitlist]: remove segmentId from dup check

Co-authored-by: Ona <no-reply@ona.com>

* fix(waitlist): reset logo animation on mouse leave

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-08 02:54:56 +00:00
badc00c43b feat(backend): add LLM-powered feed enhancement (#58)
* feat(backend): add LLM-powered feed enhancement

Add enhancement harness that fills feed item slots and
generates synthetic items via OpenRouter.

- LLM client with 30s timeout, reusable SDK instance
- Prompt builder with mini calendar and week overview
- arktype schema validation + JSON Schema for structured output
- Pure merge function with clock injection
- Defensive fallback in feed endpoint on enhancement failure
- Skips LLM call when no unfilled slots or no API key

Co-authored-by: Ona <no-reply@ona.com>

* refactor: move feed enhancement into UserSession

Move enhancement logic from HTTP handler into UserSession so the
transport layer has no knowledge of enhancement. UserSession.feed()
handles refresh, enhancement, and caching in one place.

- UserSession subscribes to engine updates and re-enhances eagerly
- Enhancement cache tracks source identity to prevent stale results
- UserSessionManager accepts config object with optional enhancer
- HTTP handler simplified to just call session.feed()

Co-authored-by: Ona <no-reply@ona.com>

* test: add schema sync tests for arktype/JSON Schema drift

Validates reference payloads against both the arktype schema
(parseEnhancementResult) and the OpenRouter JSON Schema structure.
Catches field additions/removals or type changes in either schema.

Co-authored-by: Ona <no-reply@ona.com>

* refactor: rename arktype schemas to match types

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-05 02:01:30 +00:00
277 changed files with 5112 additions and 1554 deletions

View File

@@ -0,0 +1,42 @@
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 test run: bun run test

View File

@@ -1,8 +1,8 @@
services: services:
expo: expo:
name: Expo Dev Server name: Expo Dev Server
description: Expo development server for aris-client description: Expo development server for aelis-client
triggeredBy: triggeredBy:
- postDevcontainerStart - postDevcontainerStart
commands: commands:
start: cd apps/aris-client && ./scripts/run-dev-server.sh start: cd apps/aelis-client && ./scripts/run-dev-server.sh

View File

@@ -2,7 +2,7 @@
## Project ## Project
ARIS is an AI-powered personal assistant that aggregates data from various sources into a contextual feed. Monorepo with `packages/` (shared libraries) and `apps/` (applications). AELIS is an AI-powered personal assistant that aggregates data from various sources into a contextual feed. Monorepo with `packages/` (shared libraries) and `apps/` (applications).
## Commands ## Commands

View File

@@ -1,4 +1,4 @@
# aris # aelis
To install dependencies: To install dependencies:
@@ -8,14 +8,14 @@ bun install
## Packages ## Packages
### @aris/source-tfl ### @aelis/source-tfl
TfL (Transport for London) feed source for tube, overground, and Elizabeth line alerts. TfL (Transport for London) feed source for tube, overground, and Elizabeth line alerts.
#### Testing #### Testing
```bash ```bash
cd packages/aris-source-tfl cd packages/aelis-source-tfl
bun run test bun run test
``` ```

View File

@@ -1,5 +1,5 @@
{ {
"name": "@aris/backend", "name": "@aelis/backend",
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"main": "src/server.ts", "main": "src/server.ts",
@@ -9,12 +9,12 @@
"test": "bun test src/" "test": "bun test src/"
}, },
"dependencies": { "dependencies": {
"@aris/core": "workspace:*", "@aelis/core": "workspace:*",
"@aris/source-caldav": "workspace:*", "@aelis/source-caldav": "workspace:*",
"@aris/source-google-calendar": "workspace:*", "@aelis/source-google-calendar": "workspace:*",
"@aris/source-location": "workspace:*", "@aelis/source-location": "workspace:*",
"@aris/source-tfl": "workspace:*", "@aelis/source-tfl": "workspace:*",
"@aris/source-weatherkit": "workspace:*", "@aelis/source-weatherkit": "workspace:*",
"@openrouter/sdk": "^0.9.11", "@openrouter/sdk": "^0.9.11",
"arktype": "^2.1.29", "arktype": "^2.1.29",
"better-auth": "^1", "better-auth": "^1",

View File

@@ -61,7 +61,7 @@ export async function getSessionFromHeaders(
} }
/** /**
* Test-only middleware that injects a fake user and session. * Dev/test 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,8 +69,34 @@ 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)
c.set("session", { id: "mock-session" } as AuthSession) const now = new Date()
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

@@ -0,0 +1,315 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
import { contextKey } 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[] = [],
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: [() => 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",
sourceId: "test",
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")
})
})
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"
function buildContextApp(userId?: string) {
const manager = new UserSessionManager({
providers: [() => createStubSource("weather", [], contextEntries)],
})
const app = buildTestApp(manager, userId)
const session = 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 } = 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 } = 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 } = 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 } = 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 } = 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 } = 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 } = 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 } = 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 } = 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 } = 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 } = 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 } = 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

@@ -0,0 +1,118 @@
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")
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,
})),
})
}
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")
const session = sessionManager.getOrCreate(user.id)
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

@@ -1,4 +1,4 @@
import type { FeedItem } from "@aris/core" import type { FeedItem } from "@aelis/core"
import type { LlmClient } from "./llm-client.ts" import type { LlmClient } from "./llm-client.ts"

View File

@@ -1,4 +1,4 @@
import type { FeedItem } from "@aris/core" import type { FeedItem } from "@aelis/core"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
@@ -9,6 +9,7 @@ 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

@@ -1,7 +1,9 @@
import type { FeedItem } from "@aris/core" 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.
* *
@@ -10,7 +12,11 @@ import type { EnhancementResult } from "./schema.ts"
* - 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(items: FeedItem[], result: EnhancementResult, currentTime: Date): FeedItem[] { export function mergeEnhancement(
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
@@ -31,6 +37,7 @@ export function mergeEnhancement(items: FeedItem[], result: EnhancementResult, c
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

@@ -1,4 +1,4 @@
import type { FeedItem } from "@aris/core" import type { FeedItem } from "@aelis/core"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
@@ -7,6 +7,7 @@ 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 },
@@ -60,7 +61,9 @@ 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({ insight: "Weather insight" }) expect((parsed.items as Array<Record<string, unknown>>)[0]!.slots).toEqual({
insight: "Weather insight",
})
expect((parsed.items as Array<Record<string, unknown>>)[0]!.type).toBeUndefined() expect((parsed.items as Array<Record<string, unknown>>)[0]!.type).toBeUndefined()
expect(parsed.context).toHaveLength(0) expect(parsed.context).toHaveLength(0)
}) })

View File

@@ -1,7 +1,7 @@
import type { FeedItem } from "@aris/core" import type { FeedItem } from "@aelis/core"
import { CalDavFeedItemType } from "@aris/source-caldav" import { CalDavFeedItemType } from "@aelis/source-caldav"
import { CalendarFeedItemType } from "@aris/source-google-calendar" import { CalendarFeedItemType } from "@aelis/source-google-calendar"
import systemPromptBase from "./prompts/system.txt" import systemPromptBase from "./prompts/system.txt"

View File

@@ -1,4 +1,4 @@
You are ARIS, a personal assistant. You enhance a user's feed by filling slots and optionally generating synthetic items. You are AELIS, a personal assistant. You enhance a user's feed by filling slots and optionally generating synthetic items.
The user message is a JSON object with: The user message is a JSON object with:
- "items": feed items with data and named slots to fill. Each slot has a description of what to write. - "items": feed items with data and named slots to fill. Each slot has a description of what to write.

View File

@@ -45,7 +45,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) const session = sessionManager.getOrCreate(user.id)
await session.engine.executeAction("aris.location", "update-location", { await session.engine.executeAction("aelis.location", "update-location", {
lat: result.lat, lat: result.lat,
lng: result.lng, lng: result.lng,
accuracy: result.accuracy, accuracy: result.accuracy,

View File

@@ -1,11 +1,11 @@
import { LocationSource } from "@aris/source-location" 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 { requireSession } from "./auth/session-middleware.ts" import { mockAuthSessionMiddleware, requireSession } from "./auth/session-middleware.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 { registerFeedHttpHandlers } from "./engine/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"
@@ -43,10 +43,16 @@ function main() {
app.get("/health", (c) => c.json({ status: "ok" })) app.get("/health", (c) => c.json({ status: "ok" }))
registerAuthHandlers(app) const isDev = process.env.NODE_ENV !== "production"
const authSessionMiddleware = isDev ? mockAuthSessionMiddleware("dev-user") : requireSession
if (!isDev) {
registerAuthHandlers(app)
}
registerFeedHttpHandlers(app, { registerFeedHttpHandlers(app, {
sessionManager, sessionManager,
authSessionMiddleware: requireSession, authSessionMiddleware,
}) })
registerLocationHttpHandlers(app, { sessionManager }) registerLocationHttpHandlers(app, { sessionManager })

View File

@@ -1,4 +1,4 @@
import type { FeedSource } from "@aris/core" import type { FeedSource } from "@aelis/core"
export interface FeedSourceProvider { export interface FeedSourceProvider {
feedSourceForUser(userId: string): FeedSource feedSourceForUser(userId: string): FeedSource

View File

@@ -1,6 +1,6 @@
import type { WeatherKitClient, WeatherKitResponse } from "@aris/source-weatherkit" import type { WeatherKitClient, WeatherKitResponse } from "@aelis/source-weatherkit"
import { LocationSource } from "@aris/source-location" import { LocationSource } from "@aelis/source-location"
import { describe, expect, mock, test } from "bun:test" import { describe, expect, mock, test } from "bun:test"
import { WeatherSourceProvider } from "../weather/provider.ts" import { WeatherSourceProvider } from "../weather/provider.ts"
@@ -44,8 +44,8 @@ describe("UserSessionManager", () => {
const session1 = manager.getOrCreate("user-1") const session1 = manager.getOrCreate("user-1")
const session2 = manager.getOrCreate("user-2") const session2 = manager.getOrCreate("user-2")
const source1 = session1.getSource<LocationSource>("aris.location") const source1 = session1.getSource<LocationSource>("aelis.location")
const source2 = session2.getSource<LocationSource>("aris.location") const source2 = session2.getSource<LocationSource>("aelis.location")
expect(source1).not.toBe(source2) expect(source1).not.toBe(source2)
}) })
@@ -83,7 +83,7 @@ describe("UserSessionManager", () => {
const session = manager.getOrCreate("user-1") const session = manager.getOrCreate("user-1")
expect(session.getSource("aris.weather")).toBeDefined() expect(session.getSource("aelis.weather")).toBeDefined()
}) })
test("accepts mixed providers", () => { test("accepts mixed providers", () => {
@@ -94,8 +94,8 @@ describe("UserSessionManager", () => {
const session = manager.getOrCreate("user-1") const session = manager.getOrCreate("user-1")
expect(session.getSource("aris.location")).toBeDefined() expect(session.getSource("aelis.location")).toBeDefined()
expect(session.getSource("aris.weather")).toBeDefined() expect(session.getSource("aelis.weather")).toBeDefined()
}) })
test("refresh returns feed result through session", async () => { test("refresh returns feed result through session", async () => {
@@ -114,14 +114,14 @@ describe("UserSessionManager", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] }) const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session = manager.getOrCreate("user-1") const session = manager.getOrCreate("user-1")
await session.engine.executeAction("aris.location", "update-location", { await session.engine.executeAction("aelis.location", "update-location", {
lat: 51.5074, lat: 51.5074,
lng: -0.1278, lng: -0.1278,
accuracy: 10, accuracy: 10,
timestamp: new Date(), timestamp: new Date(),
}) })
const source = session.getSource<LocationSource>("aris.location") const source = session.getSource<LocationSource>("aelis.location")
expect(source?.lastLocation?.lat).toBe(51.5074) expect(source?.lastLocation?.lat).toBe(51.5074)
}) })
@@ -132,7 +132,7 @@ describe("UserSessionManager", () => {
const session = manager.getOrCreate("user-1") const session = manager.getOrCreate("user-1")
session.engine.subscribe(callback) session.engine.subscribe(callback)
await session.engine.executeAction("aris.location", "update-location", { await session.engine.executeAction("aelis.location", "update-location", {
lat: 51.5074, lat: 51.5074,
lng: -0.1278, lng: -0.1278,
accuracy: 10, accuracy: 10,
@@ -156,7 +156,7 @@ describe("UserSessionManager", () => {
// Create new session and push location — old callback should not fire // Create new session and push location — old callback should not fire
const session2 = manager.getOrCreate("user-1") const session2 = manager.getOrCreate("user-1")
await session2.engine.executeAction("aris.location", "update-location", { await session2.engine.executeAction("aelis.location", "update-location", {
lat: 51.5074, lat: 51.5074,
lng: -0.1278, lng: -0.1278,
accuracy: 10, accuracy: 10,

View File

@@ -1,6 +1,6 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aris/core" import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
import { LocationSource } from "@aris/source-location" import { LocationSource } from "@aelis/source-location"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { UserSession } from "./user-session.ts" import { UserSession } from "./user-session.ts"
@@ -36,7 +36,7 @@ describe("UserSession", () => {
const location = new LocationSource() const location = new LocationSource()
const session = new UserSession([location]) const session = new UserSession([location])
const result = session.getSource<LocationSource>("aris.location") const result = session.getSource<LocationSource>("aelis.location")
expect(result).toBe(location) expect(result).toBe(location)
}) })
@@ -59,7 +59,7 @@ describe("UserSession", () => {
const location = new LocationSource() const location = new LocationSource()
const session = new UserSession([location]) const session = new UserSession([location])
await session.engine.executeAction("aris.location", "update-location", { await session.engine.executeAction("aelis.location", "update-location", {
lat: 51.5, lat: 51.5,
lng: -0.1, lng: -0.1,
accuracy: 10, accuracy: 10,
@@ -76,6 +76,7 @@ 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 },
@@ -93,6 +94,7 @@ 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 },
@@ -113,6 +115,7 @@ 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 },
@@ -139,6 +142,7 @@ 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 },
@@ -169,6 +173,7 @@ 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 },
@@ -190,6 +195,7 @@ 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

@@ -1,4 +1,4 @@
import { FeedEngine, type FeedItem, type FeedResult, type FeedSource } from "@aris/core" import { FeedEngine, type FeedItem, type FeedResult, type FeedSource } from "@aelis/core"
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts" import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"

View File

@@ -1,4 +1,4 @@
import { TflSource, type ITflApi } from "@aris/source-tfl" import { TflSource, type ITflApi } from "@aelis/source-tfl"
import type { FeedSourceProvider } from "../session/feed-source-provider.ts" import type { FeedSourceProvider } from "../session/feed-source-provider.ts"

View File

@@ -1,4 +1,4 @@
import { WeatherSource, type WeatherSourceOptions } from "@aris/source-weatherkit" import { WeatherSource, type WeatherSourceOptions } from "@aelis/source-weatherkit"
import type { FeedSourceProvider } from "../session/feed-source-provider.ts" import type { FeedSourceProvider } from "../session/feed-source-provider.ts"

View File

@@ -1,11 +1,11 @@
{ {
"expo": { "expo": {
"name": "Aris", "name": "Aelis",
"slug": "aris-client", "slug": "aelis-client",
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "aris", "scheme": "aelis",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "newArchEnabled": true,
"ios": { "ios": {
@@ -15,7 +15,7 @@
}, },
"ITSAppUsesNonExemptEncryption": false "ITSAppUsesNonExemptEncryption": false
}, },
"bundleIdentifier": "sh.nym.aris" "bundleIdentifier": "sh.nym.aelis"
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
@@ -26,7 +26,7 @@
}, },
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false, "predictiveBackGestureEnabled": false,
"package": "sh.nym.aris" "package": "sh.nym.aelis"
}, },
"web": { "web": {
"output": "static", "output": "static",

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 384 KiB

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,5 +1,5 @@
{ {
"name": "aris-client", "name": "aelis-client",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"main": "expo-router/entry", "main": "expo-router/entry",
@@ -18,9 +18,11 @@
"@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",
@@ -45,7 +47,8 @@
"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

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

View File

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

View File

@@ -0,0 +1,48 @@
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

@@ -0,0 +1,37 @@
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

@@ -0,0 +1,28 @@
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,18 @@
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,42 @@
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

@@ -0,0 +1,43 @@
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

Some files were not shown because too many files have changed in this diff Show More