Skip to content

Commit 9a0d323

Browse files
Agentic coding dashboard (#25)
* Add agentic coding dashboard example Bun + Aidbox FHIR Server dashboard that displays patients and their observations. Uses Bun.serve() for HTTP, @health-samurai/aidbox-client for FHIR API access, and auto-generated TypeScript types. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update skill, docker-compose, and build script - Rename skill from dashboard to aidbox-dashboard - Remove hardcoded passwords from SKILL.md, reference docker-compose.yaml - Document Chart.js integration and $materialize workflow - Expose PostgreSQL port 5432 in docker-compose.yaml - Add --upload flag to build-init-bundle script for uploading bundle and materializing ViewDefinitions in one step Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add body weight over time chart to patient dashboard Adds a ViewDefinition for body weight observations (LOINC 29463-7) that materializes into a sof.body_weight SQL table, and renders a Chart.js line chart on each patient's detail page showing their weight trend. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add README, auto-materialize ViewDefinitions in init bundle, simplify upload command - Add project README with quick start, structure, and development guide - Include $materialize entries in init-bundle.json so views are created on Aidbox startup - Add "build:init-bundle --upload" package.json script to avoid the -- separator - Change materialize valueCode from "table" to "view" - Update all references in SKILL.md and README.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add agentic coding dashboard to root README Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address PR feedback - Remove boilerplate line from CLAUDE.md - Consolidate duplicate Key Features bullets, remove Bun.SQL mentions - Add $materialize docs link - Remove repetitive sof schema references from prose - Clarify basic vs root client usage in SKILL.md - Add dashboard screenshot Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Replace view structure tree with markdown table Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add AGENTS.md and broaden AI agent prerequisite Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Restrict basic client access policy to Patient, Observation, and transactions Replace allow-all policy with two matcho policies: - Read/write access scoped to Patient and Observation resources - Separate policy for FHIR transaction bundles (used by seed script) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent eb6122a commit 9a0d323

63 files changed

Lines changed: 5432 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ A collection of examples on top of Aidbox FHIR platform
5353

5454
## Developer Experience
5555

56+
- [Agentic Coding: FHIR Patient Dashboard](developer-experience/agentic-coding-dashboard/)
5657
- [Agentic FHIR Implementation Guide Development](developer-experience/agentic-coding-ig-development/)
5758
- [Aidbox Firely .NET Client](developer-experience/aidbox-firely-dotnet-client/)
5859
- [Aidbox HAPI FHIR Client](developer-experience/aidbox-hapi-client/)
Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
---
2+
name: aidbox-dashboard
3+
description: Creating dashboards on top of Aidbox FHIR Server using ViewDefinitions
4+
---
5+
6+
# Aidbox Dashboard
7+
8+
## Aidbox FHIR Server
9+
10+
Aidbox runs locally on port 8080 via Docker Compose. All FHIR API requests use the `/fhir/` prefix.
11+
12+
## Creating Dashboards
13+
14+
Dashboards are built using the **SQL on FHIR** approach:
15+
16+
1. **Define a ViewDefinition** — a FHIR resource that describes how to flatten a FHIR resource into tabular columns
17+
2. **Upload and materialize** — run `bun run build:init-bundle --upload` to rebuild the init bundle, upload it to Aidbox, and materialize all ViewDefinitions into SQL tables in the `sof` schema
18+
3. **Query with SQL** — the app uses `Bun.SQL` to connect directly to PostgreSQL and queries `sof.<view_name>` for dashboard data
19+
4. **Render a chart** — add a Chart.js chart component in `src/index.ts` and wire it into the appropriate route handler
20+
21+
### Step 1: Create a ViewDefinition
22+
23+
ViewDefinitions are stored as JSON files in `fhir/definitions/view-definitions/`. Use numeric prefixes for ordering (e.g., `01-body-weight.json`).
24+
25+
Each file is a bundle entry with a PUT request:
26+
27+
```json
28+
{
29+
"request": {
30+
"method": "PUT",
31+
"url": "/ViewDefinition/patient-demographics"
32+
},
33+
"resource": {
34+
"resourceType": "ViewDefinition",
35+
"id": "patient-demographics",
36+
"name": "patient_demographics",
37+
"status": "active",
38+
"resource": "Patient",
39+
"select": [
40+
{
41+
"column": [
42+
{ "path": "getResourceKey()", "name": "id" },
43+
{ "path": "gender", "name": "gender" },
44+
{ "path": "birthDate", "name": "birth_date" }
45+
]
46+
},
47+
{
48+
"forEachOrNull": "name.where(use = 'official').first()",
49+
"column": [
50+
{ "path": "given.join(' ')", "name": "given_name" },
51+
{ "path": "family", "name": "family_name" }
52+
]
53+
}
54+
]
55+
}
56+
}
57+
```
58+
59+
### Step 2: Build, upload, and materialize
60+
61+
After creating or editing a ViewDefinition file, run:
62+
63+
```sh
64+
bun run build:init-bundle --upload
65+
```
66+
67+
This single command:
68+
1. Rebuilds `init-bundle.json` from all files in `fhir/definitions/`
69+
2. Uploads the bundle to Aidbox via the FHIR API (using the root client)
70+
3. Calls `$materialize` on each ViewDefinition to create/refresh the corresponding SQL table in the `sof` schema
71+
72+
Without `--upload`, it only rebuilds the JSON file locally.
73+
74+
### Step 3: Query the view with SQL
75+
76+
The app uses `Bun.SQL` (configured in `src/index.ts`) to query the `sof` schema directly:
77+
78+
```ts
79+
import { SQL } from "bun";
80+
81+
const db = new SQL({
82+
url: process.env.DATABASE_URL ?? "postgresql://aidbox:<POSTGRES_PASSWORD>@localhost:5432/aidbox",
83+
});
84+
85+
// Query a materialized view
86+
const rows = await db.unsafe(
87+
`SELECT effective_date, weight_kg, unit FROM sof.body_weight WHERE patient_id = $1 ORDER BY effective_date`,
88+
[patientId],
89+
);
90+
```
91+
92+
Get the actual `POSTGRES_PASSWORD` from `docker-compose.yaml` (`services.postgres.environment.POSTGRES_PASSWORD`).
93+
94+
### Step 4: Render a chart with Chart.js
95+
96+
Charts are rendered using [Chart.js v4](https://www.chartjs.org/) loaded via CDN (`<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>` in the Layout `<head>`).
97+
98+
See `BodyWeightChart` in `src/index.ts` for the existing pattern:
99+
100+
1. Define a TypeScript interface for the query result rows
101+
2. Create a function that returns a `<canvas>` element + an inline `<script>` that calls `new Chart()`
102+
3. Pass SQL result data as JSON-serialized `labels` and `data` arrays into the Chart.js config
103+
4. Call the function in the route handler and insert the result into the Layout
104+
105+
Example chart function pattern:
106+
107+
```ts
108+
function MyChart({ data }: { data: MyDataPoint[] }) {
109+
if (data.length === 0) {
110+
return `<div class="empty">No data found</div>`;
111+
}
112+
113+
const chartId = `my-chart-${++chartIdCounter}`;
114+
const labels = JSON.stringify(data.map((d) => d.date_column));
115+
const values = JSON.stringify(data.map((d) => d.value_column));
116+
117+
return `<div class="card">
118+
<canvas id="${chartId}"></canvas>
119+
<script>
120+
new Chart(document.getElementById('${chartId}'), {
121+
type: 'line',
122+
data: {
123+
labels: ${labels},
124+
datasets: [{
125+
label: 'My Label',
126+
data: ${values},
127+
borderColor: '#2563eb',
128+
backgroundColor: 'rgba(37, 99, 235, 0.1)',
129+
fill: true,
130+
tension: 0.3,
131+
pointRadius: 5,
132+
pointHoverRadius: 7,
133+
pointBackgroundColor: '#2563eb',
134+
pointBorderColor: '#fff',
135+
pointBorderWidth: 2,
136+
}]
137+
},
138+
options: {
139+
responsive: true,
140+
plugins: {
141+
legend: { display: false },
142+
tooltip: {
143+
callbacks: {
144+
label: (ctx) => ctx.parsed.y + ' unit'
145+
}
146+
}
147+
},
148+
scales: {
149+
x: { title: { display: true, text: 'Date' }, grid: { display: false } },
150+
y: { title: { display: true, text: 'Value' }, grace: '5%' }
151+
}
152+
}
153+
});
154+
</script>
155+
</div>`;
156+
}
157+
```
158+
159+
Chart.js supports many chart types: `line`, `bar`, `pie`, `doughnut`, `radar`, `scatter`, `bubble`. See [Chart.js docs](https://www.chartjs.org/docs/latest/) for the full API.
160+
161+
Use a unique `chartId` per chart instance (via the `chartIdCounter`) to support multiple charts on one page.
162+
163+
### Database connection
164+
165+
PostgreSQL is exposed on port 5432. Credentials are in `docker-compose.yaml` under `services.postgres.environment`:
166+
167+
| Parameter | Source in `docker-compose.yaml` |
168+
|-----------|-------------------------------|
169+
| Host | `localhost` |
170+
| Port | `5432` |
171+
| Database | `POSTGRES_DB` |
172+
| User | `POSTGRES_USER` |
173+
| Password | `POSTGRES_PASSWORD` |
174+
175+
Connection string format: `postgresql://<POSTGRES_USER>:<POSTGRES_PASSWORD>@localhost:5432/<POSTGRES_DB>`
176+
177+
## ViewDefinition Reference
178+
179+
### Structure
180+
181+
| Field | Required | Description |
182+
|-------|----------|-------------|
183+
| `resourceType` | yes | `"ViewDefinition"` |
184+
| `name` | yes | Database table name (used as `sof.<name>`). Must match `^[A-Za-z][A-Za-z0-9_]*$` |
185+
| `resource` | yes | Target FHIR resource type (e.g., `"Patient"`, `"Observation"`) |
186+
| `status` | yes | `"active"`, `"draft"`, `"retired"`, or `"unknown"` |
187+
| `select` | yes | Array of select blocks defining output columns |
188+
| `where` | no | Array of FHIRPath filter expressions |
189+
| `constant` | no | Named constants referenced as `%name` in FHIRPath |
190+
191+
### Select block
192+
193+
| Field | Description |
194+
|-------|-------------|
195+
| `column` | Array of `{ path, name }` — FHIRPath expression and output column name |
196+
| `forEach` | FHIRPath expression to iterate (creates multiple rows per resource) |
197+
| `forEachOrNull` | Like `forEach` but emits a row with nulls when the collection is empty |
198+
| `unionAll` | Combine multiple select structures |
199+
| `select` | Nested select (cross-join with parent) |
200+
201+
### Common FHIRPath expressions
202+
203+
| Expression | Description |
204+
|------------|-------------|
205+
| `getResourceKey()` | Resource ID |
206+
| `subject.getReferenceKey(Patient)` | Referenced Patient ID (for joins) |
207+
| `gender` | Direct field access |
208+
| `birthDate` | Direct field access |
209+
| `name.where(use = 'official').first()` | Filter and pick first |
210+
| `given.join(' ')` | Join array into string |
211+
| `effective.ofType(dateTime)` | Polymorphic field access |
212+
| `value.ofType(Quantity).value` | Quantity value |
213+
| `value.ofType(Quantity).unit` | Quantity unit |
214+
| `code.coding` | Iterate over codings |
215+
| `code.coding.where(system='http://loinc.org').first()` | Pick specific coding |
216+
| `code.coding.where(system = 'http://loinc.org' and code = '29463-7').exists()` | Filter by coding system + code |
217+
218+
### Example: Body Weight ViewDefinition (with filter)
219+
220+
```json
221+
{
222+
"resourceType": "ViewDefinition",
223+
"id": "body-weight",
224+
"name": "body_weight",
225+
"status": "active",
226+
"resource": "Observation",
227+
"where": [
228+
{
229+
"path": "code.coding.where(system = 'http://loinc.org' and code = '29463-7').exists()"
230+
}
231+
],
232+
"select": [
233+
{
234+
"column": [
235+
{ "path": "getResourceKey()", "name": "id" },
236+
{ "path": "subject.getReferenceKey(Patient)", "name": "patient_id" },
237+
{ "path": "effective.ofType(dateTime)", "name": "effective_date" },
238+
{ "path": "value.ofType(Quantity).value", "name": "weight_kg" },
239+
{ "path": "value.ofType(Quantity).unit", "name": "unit" },
240+
{ "path": "status", "name": "status" }
241+
]
242+
}
243+
]
244+
}
245+
```
246+
247+
This creates `sof.body_weight` with columns: `id`, `patient_id`, `effective_date`, `weight_kg`, `unit`, `status`.
248+
249+
### Example: Generic Observation ViewDefinition
250+
251+
```json
252+
{
253+
"resourceType": "ViewDefinition",
254+
"id": "observation-values",
255+
"name": "observation_values",
256+
"status": "active",
257+
"resource": "Observation",
258+
"select": [
259+
{
260+
"column": [
261+
{ "path": "getResourceKey()", "name": "id" },
262+
{ "path": "subject.getReferenceKey(Patient)", "name": "patient_id" },
263+
{ "path": "status", "name": "status" },
264+
{ "path": "effective.ofType(dateTime)", "name": "effective_date" },
265+
{ "path": "value.ofType(Quantity).value", "name": "value" },
266+
{ "path": "value.ofType(Quantity).unit", "name": "unit" }
267+
]
268+
},
269+
{
270+
"forEachOrNull": "code.coding.first()",
271+
"column": [
272+
{ "path": "system", "name": "code_system" },
273+
{ "path": "code", "name": "code" },
274+
{ "path": "display", "name": "code_display" }
275+
]
276+
}
277+
]
278+
}
279+
```
280+
281+
## Auth Clients
282+
283+
Two clients are available. Look up passwords in `docker-compose.yaml` and `fhir/definitions/access-control/`:
284+
285+
| Client | Username | Password source | Use for |
286+
|--------|----------|----------------|---------|
287+
| **Application** | `basic` | `fhir/definitions/access-control/01-client.json` (`resource.secret`) | Normal CRUD + transactions |
288+
| **Root** | `root` | `docker-compose.yaml` (`BOX_ROOT_CLIENT_SECRET`) | Admin operations, uploading init bundle |
289+
290+
The **basic** client is used by the running app (`src/aidbox.ts`) for normal FHIR CRUD operations. The **root** client is only used by `build-init-bundle` script to upload the init bundle and materialize ViewDefinitions — operations that require admin-level access.
291+
292+
## Querying Resources via FHIR API
293+
294+
```sh
295+
# Read a specific resource (get BOX_ROOT_CLIENT_SECRET from docker-compose.yaml)
296+
curl -s -u "root:<BOX_ROOT_CLIENT_SECRET>" "http://localhost:8080/fhir/Patient/<id>" | bun -e 'console.log(JSON.stringify(JSON.parse(await Bun.stdin.text()),null,2))'
297+
298+
# Search resources
299+
curl -s -u "root:<BOX_ROOT_CLIENT_SECRET>" "http://localhost:8080/fhir/Patient?name=John&_count=10" | bun -e 'console.log(JSON.stringify(JSON.parse(await Bun.stdin.text()),null,2))'
300+
```
301+
302+
Always use the `/fhir/` prefix. Without it, you get the Aidbox-native format instead of FHIR.
303+
304+
## Init Bundle
305+
306+
FHIR definitions live in `fhir/definitions/` as individual JSON files. **Never edit `init-bundle.json` directly.**
307+
308+
Files are sorted by filename, so use numeric prefixes to control order (e.g., `01-client.json` loads before `02-access-policy.json`).
309+
310+
```sh
311+
# Rebuild, upload, and materialize ViewDefinitions in one step
312+
bun run build:init-bundle --upload
313+
314+
# Rebuild only (no upload)
315+
bun run build:init-bundle
316+
```
317+
318+
The init bundle is also auto-loaded on Aidbox startup via `BOX_INIT_BUNDLE` in `docker-compose.yaml`. Note: ViewDefinitions loaded this way still need a `$materialize` call to create the SQL tables.
319+
320+
## Application Code
321+
322+
The app uses `src/aidbox.ts` which wraps the `@health-samurai/aidbox-client` SDK:
323+
324+
```ts
325+
import { aidbox } from "./aidbox";
326+
327+
// Read
328+
const result = await aidbox.read<Patient>({ type: "Patient", id: "pt-1" });
329+
330+
// Search
331+
const result = await aidbox.searchType({ type: "Patient", query: [["name", "John"], ["_count", "10"]] });
332+
333+
// Transaction
334+
await aidbox.transaction({ format: "application/fhir+json", bundle: { resourceType: "Bundle", type: "transaction", entry: [...] } });
335+
```
336+
337+
For dashboard queries, use direct SQL via `Bun.SQL` against the `sof` schema instead of the FHIR API.
338+
339+
## Debugging
340+
341+
### Check Aidbox health
342+
```sh
343+
curl -s "http://localhost:8080/health" | bun -e 'console.log(JSON.stringify(JSON.parse(await Bun.stdin.text()),null,2))'
344+
```
345+
346+
### Inspect a resource
347+
```sh
348+
curl -s -u "root:<BOX_ROOT_CLIENT_SECRET>" "http://localhost:8080/fhir/<ResourceType>/<id>" | bun -e 'console.log(JSON.stringify(JSON.parse(await Bun.stdin.text()),null,2))'
349+
```
350+
351+
### List ViewDefinitions
352+
```sh
353+
curl -s -u "root:<BOX_ROOT_CLIENT_SECRET>" "http://localhost:8080/ViewDefinition?_count=50" | bun -e 'console.log(JSON.stringify(JSON.parse(await Bun.stdin.text()),null,2))'
354+
```
355+
356+
### Test a SQL table exists
357+
```sh
358+
# Via docker exec
359+
docker compose exec postgres psql -U aidbox -d aidbox -c "SELECT * FROM sof.<view_name> LIMIT 5;"
360+
```

0 commit comments

Comments
 (0)