Longitudinal Data Collection: Coffee Tracker v2
Overview
In this guide, we extend Coffee Tracker with longitudinal data — the ability to follow up on initial registrations with related observations.
You'll learn:
- How to link forms (one-to-many relationships)
- How to use the Formulus API to query and open forms
- How to inject data across linked forms
- How to build dashboards around collected data
Time to complete: 60–90 minutes
Prerequisites:
- Completed v1 guide
- Basic JavaScript knowledge
- Access to Synkronus and Formulus
What is longitudinal data?
Longitudinal data means following up on an entity over time:
Register Coffee (v1.0)
↓
├→ Pull Shot (first follow-up)
├→ Pull Shot (second follow-up)
└→ Pull Shot (third follow-up)
In ODE terms:
- One-to-many relationship between
register_coffeeandpull_shotobservations - Follow-ups include a reference to the original (e.g.,
bean: "Prainema") - The app queries and displays related data
Part 1: Create the follow-up form
Add pull_shot form
Create a new form for "shots pulled" (espresso shots). Folder structure:
forms/
├── register_coffee/
│ ├── schema.json
│ └── ui.json
└── pull_shot/ # NEW
├── schema.json
└── ui.json
pull_shot/schema.json
{
"type": "object",
"properties": {
"bean": {
"type": "string",
"title": "Bean name",
"description": "Which coffee bean is this shot from?"
},
"yield": {
"type": "number",
"title": "Yield (grams)",
"description": "Output weight in grams"
},
"time": {
"type": "number",
"title": "Time (seconds)",
"description": "How long the shot took"
},
"rating": {
"type": "string",
"title": "Rating",
"enum": ["poor", "fair", "good", "excellent"]
},
"notes": {
"type": "string",
"title": "Notes",
"description": "Optional tasting notes"
}
},
"required": ["bean", "yield", "time"]
}
pull_shot/ui.json
{
"type": "SwipeLayout",
"options": {
"headerTitle": "Pull Shot",
"headerFields": ["bean"]
},
"elements": [
{
"type": "Control",
"scope": "#/properties/bean",
"options": {
"readOnly": true
}
},
{
"type": "Control",
"scope": "#/properties/yield"
},
{
"type": "Control",
"scope": "#/properties/time"
},
{
"type": "Control",
"scope": "#/properties/rating"
},
{
"type": "Control",
"scope": "#/properties/notes"
}
]
}
Key detail: The bean field is readOnly — we'll inject the value from the app, so users don't enter it manually.
Part 2: Build the app with data access
New app structure
app/
├── index.html
├── index.js
├── details.html
├── details.js
├── style.css
├── formulus-load.js # Helper to access Formulus API
└── coffee_cup.png
Get the Formulus API helper
Download formulus-load.js from ODE repo and save it in your app/ folder.
index.html — Coffee list
Create app/index.html:
<!DOCTYPE html>
<html>
<head>
<title>Coffee Tracker v2.0</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Coffee Tracker</h1>
<h2>v2.0 — Longitudinal Data</h2>
<table id="beans-table" class="beans-list">
<thead>
<tr>
<th>Bean</th>
<th>Country</th>
<th></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<p id="loading">Loading registered coffees...</p>
</div>
<script src="formulus-load.js"></script>
<script src="index.js"></script>
</body>
</html>
index.js — Query and display coffees
Create app/index.js:
/**
* Display registered coffees in a table
* @param {Array} beans - Array of coffee observations
*/
async function updateBeans(beans) {
const beansTable = document.getElementById('beans-table').querySelector('tbody');
const loading = document.getElementById('loading');
loading.style.display = 'none';
if (beans.length === 0) {
beansTable.innerHTML = '<tr><td colspan="3">No coffees registered yet.</td></tr>';
return;
}
beans.forEach(bean => {
const data = bean.data || {};
beansTable.innerHTML += `
<tr>
<td><strong>${data.name || 'Unknown'}</strong></td>
<td>${data.origin || '—'}</td>
<td>
<button onclick="goToDetails('${data.name}')">Details</button>
</td>
</tr>
`;
});
}
/**
* Navigate to details page
*/
function goToDetails(beanName) {
window.location.href = `details.html?bean=${encodeURIComponent(beanName)}`;
}
/**
* Initialize: fetch coffees and display
*/
async function init() {
try {
const api = await getFormulus();
const observations = await api.getObservations('register_coffee');
updateBeans(observations);
} catch (err) {
console.error('Error loading coffees:', err);
document.getElementById('loading').textContent = 'Error loading coffees. See console.';
}
}
// Start when page loads
document.addEventListener('DOMContentLoaded', init);
details.html — Coffee details + shots
Create app/details.html:
<!DOCTYPE html>
<html>
<head>
<title>Coffee Details - Coffee Tracker v2</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<button onclick="history.back()" class="back-btn">← Back</button>
<div id="coffee-details" class="details">
<!-- Coffee details will be inserted here -->
</div>
<div id="shots">
<h3>Shots Pulled</h3>
<button onclick="addShot()" class="btn-primary">+ Pull Shot</button>
<table id="shots-table" class="shots-list">
<thead>
<tr>
<th>Yield</th>
<th>Time</th>
<th>Rating</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
<script src="formulus-load.js"></script>
<script src="details.js"></script>
</body>
</html>
details.js — Display linked data
Create app/details.js:
/**
* Get query parameter from URL
*/
function getQueryParam(name) {
const url = new URL(window.location);
return url.searchParams.get(name);
}
/**
* Display coffee details
*/
async function displayCoffee(beanName) {
try {
const api = await getFormulus();
// Fetch all registered coffees
const coffees = await api.getObservations('register_coffee');
const match = coffees.find(obs => (obs.data || {}).name === beanName);
if (!match) {
document.getElementById('coffee-details').innerHTML = '<p>Coffee not found.</p>';
return;
}
const data = match.data || {};
const detailsDiv = document.getElementById('coffee-details');
detailsDiv.innerHTML = `
<h2>${data.name || 'Unknown'}</h2>
<dl>
<dt>Origin</dt>
<dd>${data.origin || '—'}</dd>
<dt>Roaster</dt>
<dd>${data.roaster || '—'}</dd>
<dt>Roast Level</dt>
<dd>${data.roast_profile || '—'}</dd>
<dt>Roasted</dt>
<dd>${data.roast_date ? new Date(data.roast_date).toLocaleDateString() : '—'}</dd>
</dl>
`;
// Fetch and display shots pulled for this coffee
displayShots(api, beanName);
// Store bean name for adding shots
window.currentBeanName = beanName;
} catch (err) {
console.error('Error loading coffee details:', err);
}
}
/**
* Display shots pulled for this coffee
*/
async function displayShots(api, beanName) {
const shots = await api.getObservations('pull_shot');
const relatedShots = shots.filter(obs => (obs.data || {}).bean === beanName);
const table = document.getElementById('shots-table').querySelector('tbody');
if (relatedShots.length === 0) {
table.innerHTML = '<tr><td colspan="3">No shots pulled yet.</td></tr>';
return;
}
relatedShots.forEach(shot => {
const data = shot.data || {};
table.innerHTML += `
<tr>
<td>${data.yield || '—'} g</td>
<td>${data.time || '—'} s</td>
<td>${data.rating || '—'}</td>
</tr>
`;
});
}
/**
* Open form to pull a new shot (with data injection)
*/
async function addShot() {
const beanName = window.currentBeanName;
if (!beanName) {
alert('Please select a coffee first.');
return;
}
try {
const api = await getFormulus();
// Open pull_shot form and inject the bean name
await api.openFormplayer(
'pull_shot',
{ bean: beanName }, // Injected values
{} // Additional options
);
// Refresh the page after user closes the form
location.reload();
} catch (err) {
console.error('Error opening form:', err);
alert('Failed to open form. See console.');
}
}
/**
* Initialize: get bean name from URL and load details
*/
document.addEventListener('DOMContentLoaded', () => {
const beanName = getQueryParam('bean');
if (beanName) {
displayCoffee(decodeURIComponent(beanName));
}
});
Updated style.css
Update app/style.css:
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #6B4423 0%, #9C2C07 100%);
color: #333;
margin: 0;
padding: 16px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 32px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
h1 {
color: #9C2C07;
margin: 0 0 8px;
}
h2 {
color: #333;
margin: 0 0 24px;
font-size: 24px;
}
h3 {
color: #9C2C07;
margin-top: 32px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background: #f5f5f5;
font-weight: bold;
color: #9C2C07;
}
button {
background: #9C2C07;
color: white;
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #7a2306;
}
.btn-primary {
margin: 16px 0;
padding: 12px 24px;
font-size: 16px;
}
.back-btn {
margin-bottom: 16px;
background: #666;
padding: 8px 12px;
}
.back-btn:hover {
background: #444;
}
dl {
display: grid;
grid-template-columns: 100px 1fr;
gap: 12px;
margin: 16px 0;
}
dt {
font-weight: bold;
color: #9C2C07;
}
dd {
margin: 0;
color: #555;
}
#loading {
text-align: center;
color: #666;
padding: 32px;
}
Part 3: Bundle and upload
Same as v1, but now with more files:
zip -r coffee_tracker-v2.0.0.zip app/ forms/
Upload to Synkronus:
./synk app-bundle upload ./coffee_tracker-v2.0.0.zip -a
Part 4: Test in Formulus
- Sync the new app in Formulus
- Open Coffee Tracker
- Register a few coffees
- Click "Details" to see the details page
- Click "+ Pull Shot" to open the follow-up form
- Notice the bean name is pre-filled
- Complete multiple shots for the same coffee
- Return to details — see all related shots listed
How it works: Data injection
When you call:
await api.openFormplayer(
'pull_shot',
{ bean: beanName }, // Inject this value
{}
)
The Formulus API:
- Opens the
pull_shotform - Pre-fills the
beanfield with the provided value - Marks it read-only (from ui.json)
- User fills in the rest (yield, time, rating)
- When submitted, the observation includes the injected value
API reference
getObservations(formName)
const observations = await api.getObservations('register_coffee');
// Returns array of observation objects: [{ id, data, ... }, ...]
openFormplayer(formName, injection, options)
await api.openFormplayer(
'pull_shot',
{ bean: 'Prainema', roaster: 'Local' },
{ readOnly: ['roaster'] } // Optional: make fields read-only
);
Troubleshooting
API not available:
- Ensure
formulus-load.jsis included - Check browser console for errors
Data not injecting:
- Verify field name matches schema
- Check
readOnlyin ui.json matches intended read-only fields
Shots not appearing:
- Verify
beanfield value matches exactly (case-sensitive) - Check Synkronus logs for form submission errors
Next steps
You've built a complete longitudinal data collection app! Next:
- Add dashboards — Visualize shot results with charts
- Use a framework — Upgrade from vanilla JS to React/SolidJS
- Set up CI/CD — Auto-deploy on code changes
- Scale it — Add more forms, more data relationships
Reference
- Formulus API Docs — Full API reference
- JSONForms Spec — Form details
- App Bundle Format — Bundle structure
- Forum — Get help
Congratulations! You've mastered longitudinal data. Time to pour that espresso! ☕