chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// A coffee roast log kept as atproto records. Deploy it to your own PDS
// with PDS_URL, PDS_DID and PDS_APP_PASSWORD set; see docs/apps.md.
//
// Records are com.chadtmiller.coffee.roast in the owner's repo. Reads are
// public through dev.pdsjs.query.run; writes need an app password, which the
// app exchanges for a session against the owner's own PDS. Photos are
// ordinary blobs, shown through the site's /.blobs/<cid> route.
//
// Usage: PDS_APP_PASSWORD=... node build-roasts-site.mjs
const PDS = process.env.PDS_URL;
const DID = process.env.PDS_DID;
if (!PDS || !DID) throw new Error('Set PDS_URL and PDS_DID.');
const SITE = 'roasts-app';
const SITES = 'dev.pdsjs.site.deploy';
const DEFS = 'dev.pdsjs.query.def';
// The collection roasts are written to. An NSID's authority is every
// segment but the last, reversed, so this names a domain you control.
const ROASTS = process.env.ROAST_COLLECTION || 'com.chadtmiller.coffee.roast';
async function xrpc(path, init = {}) {
const response = await fetch(`${PDS}/xrpc/${path}`, init);
if (!response.ok) {
throw new Error(
`${path.split('?')[0]} ${response.status}: ${(await response.text()).slice(0, 200)}`,
);
}
return response.json();
}
const password = process.env.PDS_APP_PASSWORD;
if (!password) throw new Error('Set PDS_APP_PASSWORD.');
const session = await xrpc('com.atproto.server.createSession', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: DID, password }),
});
const auth = { Authorization: `Bearer ${session.accessJwt}` };
/** @param {string} collection @param {string} rkey @param {Record<string, unknown>} record */
async function put(collection, rkey, record) {
await xrpc('com.atproto.repo.putRecord', {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({
repo: DID,
collection,
rkey,
record: { $type: collection, ...record },
}),
});
console.error(`put ${collection}/${rkey}`);
}
// ---- the query the app reads ----
await put(DEFS, 'coffee-roasts', {
collection: ROASTS,
sort: { field: 'roastedAt', direction: 'desc' },
createdAt: new Date().toISOString(),
});
// ---- the app ----
const HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Roasts</title>
<meta name="description" content="A coffee roast log kept as records in an atproto repo.">
<style>
:root{color-scheme:dark;--bg:#12100e;--card:#1c1917;--line:#2a2522;--fg:#f0ebe6;--dim:#a8a29e;--accent:#c98a4b}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:system-ui,-apple-system,sans-serif;line-height:1.5;-webkit-text-size-adjust:100%}
a{color:inherit;text-decoration:none}
button{font:inherit;cursor:pointer}
.wrap{max-width:820px;margin:0 auto;padding:0 18px 80px}
header.site{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding:32px 0 20px;flex-wrap:wrap}
header.site h1{margin:0;font-size:24px;letter-spacing:-.02em}
header.site .who{color:var(--dim);font-size:13px}
.btn{border:0;background:var(--accent);color:#1a1207;padding:9px 16px;border-radius:10px;font-weight:650;font-size:14px}
.btn:hover{filter:brightness(1.08)}
.btn.ghost{background:transparent;color:var(--dim);border:1px solid var(--line)}
.btn.ghost:hover{color:var(--fg)}
.btn:disabled{opacity:.45;cursor:default;filter:none}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;margin-bottom:22px}
.stat{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:12px 14px}
.stat .v{font-size:20px;font-weight:650;letter-spacing:-.01em}
.stat .k{color:var(--dim);font-size:11.5px;text-transform:uppercase;letter-spacing:.04em;margin-top:2px}
.roast{display:flex;align-items:center;gap:14px;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:13px 15px;margin-bottom:9px}
.roast:hover{border-color:#3a332e}
.roast .thumb{width:46px;height:46px;border-radius:9px;object-fit:cover;background:#241f1c;flex:none}
.roast .main{min-width:0;flex:1}
.roast .bean{font-weight:600;font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.roast .sub{color:var(--dim);font-size:12.5px;margin-top:1px}
.roast .right{text-align:right;flex:none}
.roast .loss{font-size:16px;font-weight:650;font-variant-numeric:tabular-nums}
.level{display:inline-block;font-size:10.5px;font-weight:650;text-transform:uppercase;letter-spacing:.04em;padding:2px 7px;border-radius:20px;margin-top:3px}
.lv0{background:#33372c;color:#c2ceb0}.lv1{background:#4a3d20;color:#f5d9a0}.lv2{background:#4a3418;color:#f5c07a}
.lv3{background:#4a2f14;color:#e8a765}.lv4{background:#452a12;color:#dd9a58}.lv5{background:#3d2410;color:#d9924e}
.lv6{background:#35200e;color:#cc8442}.lv7{background:#2a180a;color:#bd7a3c}.lv8{background:#1f1006;color:#a8632c}
label{display:block;margin-bottom:12px;font-size:13px;color:var(--dim)}
label input,label select,label textarea{display:block;width:100%;margin-top:5px;background:#171310;border:1px solid var(--line);color:var(--fg);border-radius:9px;padding:9px 11px;font:inherit;font-size:14.5px}
label textarea{min-height:78px;resize:vertical}
label input[type=file]{padding:8px 10px;font-size:13px;color:var(--dim)}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:0 14px}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px}
h2{font-size:15px;margin:26px 0 12px;letter-spacing:-.01em}
h2:first-child{margin-top:0}
.timer{text-align:center;padding:8px 0 18px}
.clock{font-size:66px;font-weight:250;font-variant-numeric:tabular-nums;letter-spacing:-.03em;line-height:1}
.clock.run{color:var(--accent)}
.clock.done{color:var(--fg)}
.hint{color:var(--dim);font-size:13px;margin-top:8px;min-height:19px}
.hint b{color:var(--accent);font-weight:650}
.marks{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;margin-top:16px}
.mark{background:#171310;border:1px solid var(--line);color:var(--fg);border-radius:10px;padding:12px 8px;font-size:13.5px;font-weight:600}
.mark:hover{border-color:var(--accent)}
.mark.done{background:#241a10;border-color:#4a3418;color:var(--accent)}
.mark small{display:block;color:var(--dim);font-weight:400;font-size:11.5px;margin-top:2px;font-variant-numeric:tabular-nums}
.mark.done small{color:#c9a173}
.tl{margin:0;padding:0;list-style:none}
.tl li{display:flex;gap:12px;padding:7px 0;border-bottom:1px solid var(--line);font-size:13.5px}
.tl li:last-child{border-bottom:0}
.tl .t{font-variant-numeric:tabular-nums;color:var(--accent);min-width:56px;font-weight:600}
.tl .d{color:var(--dim);margin-left:auto;font-size:12.5px}
.photos{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
.photos img{width:104px;height:104px;object-fit:cover;border-radius:10px;background:#241f1c}
.row{display:flex;gap:9px;align-items:center;flex-wrap:wrap}
.right-end{margin-left:auto}
.note{white-space:pre-line;color:#ddd5cd;font-size:14px}
.dim{color:var(--dim)}
.err{background:#3a1a1a;border:1px solid #63302f;color:#f2b8b5;padding:10px 13px;border-radius:10px;font-size:13.5px;margin-bottom:14px}
.ok{background:#1c2b1e;border:1px solid #33502f;color:#b7e0b0;padding:10px 13px;border-radius:10px;font-size:13.5px;margin-bottom:14px}
.empty{text-align:center;color:var(--dim);padding:44px 0;font-size:14.5px}
nav.back{padding:24px 0 6px;font-size:13.5px;color:var(--dim)}
nav.back a:hover{color:var(--fg)}
footer{margin-top:48px;color:#6b625b;font-size:12.5px;line-height:1.7}
footer a{color:var(--dim)}
dialog{background:var(--card);color:var(--fg);border:1px solid var(--line);border-radius:14px;padding:20px;max-width:380px;width:calc(100% - 32px)}
dialog::backdrop{background:rgba(0,0,0,.66)}
@media (max-width:560px){.grid2{grid-template-columns:1fr}.clock{font-size:54px}}
</style>
</head>
<body>
<div class="wrap" id="app"><p class="empty">Loading…</p></div>
<script>
const ROASTS = ${JSON.stringify(ROASTS)};
// Whose repo this is: asked of the site's own origin, so the app carries no
// identity of its own and serves whoever deploys it.
let PDS = null, DID = null, HANDLE = null;
let SESSION = null; // set once an app password is exchanged
let ROASTLIST = [];
let TIMER = null; // the live roast in progress
// replaceChildren renders a null child as the text "null", so every list of
// children passes through here.
const paint = (node, ...kids) => node.replaceChildren(...kids.filter(Boolean));
const $ = (tag, attrs, kids) => {
const n = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (k === 'text') n.textContent = v;
else if (k === 'html') n.innerHTML = v;
else if (k.startsWith('on')) n.addEventListener(k.slice(2), v);
else if (v !== null && v !== undefined) n.setAttribute(k, v);
}
for (const kid of kids || []) if (kid) n.appendChild(kid);
return n;
};
// ---- domain ----
// Sweet Maria's Roast Color Card, the home-roasting reference: each named
// degree paired with the weight loss their sample shows at it, its Agtron
// number on the SCA scale, and an approximate bean temperature. Read from
// the top down, so a loss lands in the darkest degree it reaches. These
// numbers run lower than commercial charts; green moisture moves them, so
// they are a guide rather than a measurement.
const LEVELS = [
{ min: 16.6, name: 'Burnt', agtron: 25, temp: '455F+', cls: 'lv8' },
{ min: 15.6, name: 'French', agtron: 35, temp: '448F', cls: 'lv7' },
{ min: 15.1, name: 'Full City+', agtron: 45, temp: '442F', cls: 'lv6' },
{ min: 14.5, name: 'Full City', agtron: 55, temp: '438F', cls: 'lv5' },
{ min: 13.3, name: 'City+', agtron: 65, temp: '432F', cls: 'lv4' },
{ min: 12.7, name: 'City', agtron: 75, temp: '425F', cls: 'lv3' },
{ min: 11.5, name: 'City-', agtron: 85, temp: '418F', cls: 'lv2' },
{ min: 10.3, name: '1st Crack', agtron: 95, temp: '412F', cls: 'lv1' },
{ min: 0, name: 'Under-developed', agtron: null, temp: null, cls: 'lv0' },
];
const EVENTS = [
{ kind: 'dryEnd', label: 'Dry end' },
{ kind: 'firstCrackStart', label: 'First crack' },
{ kind: 'firstCrackEnd', label: 'FC end' },
{ kind: 'secondCrack', label: 'Second crack' },
{ kind: 'drop', label: 'Drop' },
];
const EVENT_LABEL = Object.fromEntries(EVENTS.map((e) => [e.kind, e.label]));
const clock = (s) => {
if (s === null || s === undefined) return '–';
const m = Math.floor(s / 60);
return m + ':' + String(Math.floor(s % 60)).padStart(2, '0');
};
const fmtDate = (iso) =>
iso ? new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '';
function lossOf(r) {
const g = Number(r.greenWeight), o = Number(r.roastedWeight);
if (!g || !o || o > g) return null;
// Rounded to the figure the page shows, so a displayed 15.0% is never
// labelled by the band below 15.
return Math.round(((g - o) / g) * 1000) / 10;
}
const levelOf = (loss) => (loss === null ? null : LEVELS.find((l) => loss >= l.min));
const eventAt = (r, kind) => {
const e = (r.events || []).find((x) => x.kind === kind);
return e ? e.at : null;
};
/** Development time ratio: the share of the roast spent after first crack. */
function dtrOf(r) {
const fc = eventAt(r, 'firstCrackStart');
const end = r.totalTime || eventAt(r, 'drop');
if (fc === null || !end || end <= fc) return null;
return ((end - fc) / end) * 100;
}
// ---- talking to the PDS ----
async function discover() {
DID = (await (await fetch('/.well-known/atproto-did')).text()).trim();
const override = new URLSearchParams(location.search).get('pds');
if (override) {
PDS = override.endsWith('/') ? override.slice(0, -1) : override;
} else {
const doc = await (await fetch('https://plc.directory/' + DID)).json();
PDS = (doc.service || []).find(
(s) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer',
).serviceEndpoint;
}
try {
const repo = await (await fetch(PDS + '/xrpc/com.atproto.repo.describeRepo?repo=' + DID)).json();
HANDLE = repo.handle || DID;
} catch { HANDLE = DID; }
try { SESSION = JSON.parse(localStorage.getItem('roast-session') || 'null'); } catch {}
}
async function loadRoasts() {
// The query def sorts by roastedAt; listRecords is the fallback when a
// deployment does not serve queries.
try {
const r = await fetch(PDS + '/xrpc/dev.pdsjs.query.run?query=coffee-roasts&limit=500');
if (r.ok) {
ROASTLIST = (await r.json()).records.map((x) => ({ rkey: x.uri.split('/').pop(), ...x.value }));
return;
}
} catch {}
const r = await fetch(PDS + '/xrpc/com.atproto.repo.listRecords?repo=' + DID + '&collection=' + ROASTS + '&limit=100&reverse=true');
ROASTLIST = r.ok
? (await r.json()).records.map((x) => ({ rkey: x.uri.split('/').pop(), ...x.value }))
.sort((a, b) => String(b.roastedAt).localeCompare(String(a.roastedAt)))
: [];
}
async function signIn(identifier, appPassword) {
const r = await fetch(PDS + '/xrpc/com.atproto.server.createSession', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, password: appPassword }),
});
if (!r.ok) throw new Error('That did not sign in. Check the app password.');
const s = await r.json();
if (s.did !== DID) throw new Error('That account does not own this site.');
SESSION = s;
localStorage.setItem('roast-session', JSON.stringify(s));
}
function signOut() {
SESSION = null;
localStorage.removeItem('roast-session');
route();
}
/** An authorized call, refreshing the session once if the token has aged out. */
async function authed(path, init = {}, retry = true) {
if (!SESSION) throw new Error('Sign in to save roasts.');
const r = await fetch(PDS + '/xrpc/' + path, {
...init,
headers: { ...(init.headers || {}), Authorization: 'Bearer ' + SESSION.accessJwt },
});
if (r.status === 401 && retry) {
const rf = await fetch(PDS + '/xrpc/com.atproto.server.refreshSession', {
method: 'POST',
headers: { Authorization: 'Bearer ' + SESSION.refreshJwt },
});
if (!rf.ok) { signOut(); throw new Error('Session expired. Sign in again.'); }
SESSION = await rf.json();
localStorage.setItem('roast-session', JSON.stringify(SESSION));
return authed(path, init, false);
}
if (!r.ok) {
const body = await r.json().catch(() => ({}));
throw new Error(body.message || body.error || ('Request failed (' + r.status + ')'));
}
return r.json();
}
const saveRoast = (rkey, record) =>
authed('com.atproto.repo.putRecord', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo: DID, collection: ROASTS, rkey, record: { $type: ROASTS, ...record } }),
});
const deleteRoast = (rkey) =>
authed('com.atproto.repo.deleteRecord', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo: DID, collection: ROASTS, rkey }),
});
async function uploadPhoto(file) {
const bytes = new Uint8Array(await file.arrayBuffer());
const out = await authed('com.atproto.repo.uploadBlob', {
method: 'POST',
headers: { 'Content-Type': file.type || 'image/jpeg' },
body: bytes,
});
return out.blob;
}
const photoUrl = (blob) => '/.blobs/' + (blob?.ref?.$link || blob?.ref);
// A record key that sorts by time, so the repo reads in roast order.
const newKey = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
// ---- views ----
function header() {
const right = $('div', { class: 'row' }, [
SESSION ? $('button', { class: 'btn', text: 'New roast', onclick: () => go(null, '/new') }) : null,
SESSION
? $('button', { class: 'btn ghost', text: 'Sign out', onclick: signOut })
: $('button', { class: 'btn ghost', text: 'Sign in', onclick: openSignIn }),
]);
return $('header', { class: 'site' }, [
$('div', {}, [
$('h1', { text: 'Roasts' }),
$('div', { class: 'who', text: HANDLE + " \\u00b7 Behmor 1600 Plus" }),
]),
right,
]);
}
function footer() {
const f = $('footer', {});
f.append('Every roast here is a record in ');
f.appendChild($('a', { href: 'https://bsky.app/profile/' + HANDLE, text: HANDLE }));
f.append("'s atproto repo, queried live from it. Served by pds.js.");
return f;
}
function openSignIn() {
const dlg = $('dialog', {});
const id = $('input', { type: 'text', value: HANDLE, autocomplete: 'username' });
const pw = $('input', { type: 'password', placeholder: 'xxxx-xxxx-xxxx-xxxx', autocomplete: 'current-password' });
const err = $('div', {});
const submit = async () => {
err.replaceChildren();
try {
await signIn(id.value.trim(), pw.value.trim());
dlg.close();
route();
} catch (e) {
err.replaceChildren($('div', { class: 'err', text: e.message }));
}
};
pw.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
dlg.append(
$('h2', { text: 'Sign in to log roasts', style: 'margin-top:0' }),
err,
$('label', { text: 'Handle or DID' }, [id]),
$('label', { text: 'App password' }, [pw]),
$('p', { class: 'dim', style: 'font-size:12.5px', text: 'Make one under Sign-in & security in your account. Reading needs no sign in.' }),
$('div', { class: 'row', style: 'justify-content:flex-end;margin-top:6px' }, [
$('button', { class: 'btn ghost', text: 'Cancel', onclick: () => dlg.close() }),
$('button', { class: 'btn', text: 'Sign in', onclick: submit }),
]),
);
document.body.appendChild(dlg);
dlg.addEventListener('close', () => dlg.remove());
dlg.showModal();
setTimeout(() => pw.focus(), 50);
}
function renderList(app) {
document.title = 'Roasts \\u00b7 ' + HANDLE;
const done = ROASTLIST.filter((r) => lossOf(r) !== null);
const avgLoss = done.length ? done.reduce((s, r) => s + lossOf(r), 0) / done.length : null;
const dtrs = ROASTLIST.map(dtrOf).filter((x) => x !== null);
const avgDtr = dtrs.length ? dtrs.reduce((a, b) => a + b, 0) / dtrs.length : null;
const green = ROASTLIST.reduce((s, r) => s + (Number(r.greenWeight) || 0), 0);
const stats = $('div', { class: 'stats' }, [
$('div', { class: 'stat' }, [
$('div', { class: 'v', text: String(ROASTLIST.length) }),
$('div', { class: 'k', text: 'Roasts' }),
]),
$('div', { class: 'stat' }, [
$('div', { class: 'v', text: avgLoss === null ? '–' : avgLoss.toFixed(1) + '%' }),
$('div', { class: 'k', text: 'Avg loss' }),
]),
$('div', { class: 'stat' }, [
$('div', { class: 'v', text: avgDtr === null ? '–' : avgDtr.toFixed(0) + '%' }),
$('div', { class: 'k', text: 'Avg DTR' }),
]),
$('div', { class: 'stat' }, [
$('div', { class: 'v', text: green >= 1000 ? (green / 1000).toFixed(1) + 'kg' : green + 'g' }),
$('div', { class: 'k', text: 'Green roasted' }),
]),
]);
const list = $('div', {});
if (!ROASTLIST.length) {
list.appendChild($('p', { class: 'empty', text: SESSION ? 'No roasts yet. Start one.' : 'No roasts logged yet.' }));
}
for (const r of ROASTLIST) {
const loss = lossOf(r);
const level = levelOf(loss);
const bits = [fmtDate(r.roastedAt)];
if (r.greenWeight) bits.push(r.greenWeight + 'g');
const total = r.totalTime || eventAt(r, 'drop');
if (total) bits.push(clock(total));
if (r.machine?.profile) bits.push(r.machine.profile);
const photo = (r.photos || [])[0];
const row = $('a', { class: 'roast', href: '/r/' + r.rkey, onclick: (e) => go(e, '/r/' + r.rkey) }, [
photo ? $('img', { class: 'thumb', src: photoUrl(photo), alt: '', loading: 'lazy' }) : null,
$('div', { class: 'main' }, [
$('div', { class: 'bean', text: r.bean || 'Untitled roast' }),
$('div', { class: 'sub', text: bits.join(' \\u00b7 ') }),
]),
$('div', { class: 'right' }, [
$('div', { class: 'loss', text: loss === null ? '–' : loss.toFixed(1) + '%' }),
level ? $('span', { class: 'level ' + level.cls, text: level.name }) : null,
]),
]);
list.appendChild(row);
}
paint(app, header(), stats, list, footer());
}
/** Fields shared by the setup step and the edit form. */
function roastFields(r) {
const f = {};
const mk = (key, label, attrs) => {
f[key] = $('input', attrs || { type: 'text' });
if (r && r[key] !== undefined && r[key] !== null) f[key].value = r[key];
return $('label', { text: label }, [f[key]]);
};
const bean = mk('bean', 'Bean', { type: 'text', placeholder: 'Ethiopia Yirgacheffe' });
const origin = mk('origin', 'Origin / process', { type: 'text', placeholder: 'Washed, Gedeb' });
const green = mk('greenWeight', 'Green weight (g)', { type: 'number', inputmode: 'decimal', placeholder: '227' });
const roasted = mk('roastedWeight', 'Roasted weight (g)', { type: 'number', inputmode: 'decimal', placeholder: 'after cooling' });
f.profile = $('select', {});
for (const p of ['', 'P1', 'P2', 'P3', 'P4', 'P5']) {
f.profile.appendChild($('option', { value: p, text: p || 'Profile…' }));
}
f.batch = $('select', {});
for (const b of ['', '1/4 lb', '1/2 lb', '1 lb']) {
f.batch.appendChild($('option', { value: b, text: b || 'Batch size…' }));
}
if (r?.machine) { f.profile.value = r.machine.profile || ''; f.batch.value = r.machine.batchSize || ''; }
return {
fields: f,
setup: [
bean,
origin,
$('div', { class: 'grid2' }, [green, roasted]),
$('div', { class: 'grid2' }, [
$('label', { text: 'Behmor profile' }, [f.profile]),
$('label', { text: 'Batch setting' }, [f.batch]),
]),
],
read: () => ({
bean: f.bean.value.trim(),
origin: f.origin.value.trim() || undefined,
greenWeight: f.greenWeight.value ? Number(f.greenWeight.value) : undefined,
roastedWeight: f.roastedWeight.value ? Number(f.roastedWeight.value) : undefined,
machine: {
name: 'Behmor 1600 Plus',
profile: f.profile.value || undefined,
batchSize: f.batch.value || undefined,
},
}),
};
}
function renderNew(app) {
document.title = 'New roast';
if (!SESSION) { openSignIn(); return renderList(app); }
document.body.dataset.keepAwake = '1';
const state = TIMER || { startedAt: null, events: [], record: null };
TIMER = state;
const form = roastFields(state.record);
const err = $('div', {});
const clockEl = $('div', { class: 'clock', text: '0:00' });
const hint = $('div', { class: 'hint' });
const marks = $('div', { class: 'marks' });
const elapsed = () => (state.startedAt ? Math.floor((Date.now() - state.startedAt) / 1000) : 0);
const markAt = (kind) => {
const e = state.events.find((x) => x.kind === kind);
return e ? e.at : null;
};
const drawMarks = () => {
marks.replaceChildren();
for (const e of EVENTS) {
const at = state.events.find((x) => x.kind === e.kind);
const b = $('button', {
class: 'mark' + (at ? ' done' : ''),
onclick: () => {
if (!state.startedAt) return;
const i = state.events.findIndex((x) => x.kind === e.kind);
if (i >= 0) state.events.splice(i, 1);
else state.events.push({ kind: e.kind, at: elapsed() });
drawMarks();
tick();
},
}, [
$('span', { text: e.label }),
$('small', { text: at ? clock(at.at) : 'tap at the moment' }),
]);
marks.appendChild(b);
}
};
drawMarks();
// Drop ends the roast: the clock holds at that time rather than running
// on. Un-marking drop starts it moving again, since the interval keeps
// ticking and only the reading is frozen.
const tick = () => {
if (!state.startedAt) return;
const dropped = markAt('drop');
clockEl.textContent = clock(dropped === null ? elapsed() : dropped);
clockEl.className = 'clock ' + (dropped === null ? 'run' : 'done');
const fc = markAt('firstCrackStart');
if (dropped !== null && fc !== null && dropped > fc) {
const dtr = (((dropped - fc) / dropped) * 100).toFixed(0);
hint.replaceChildren('Roast complete \u00b7 ', $('b', { text: dtr + '% development' }));
} else if (dropped !== null) {
hint.replaceChildren('Roast complete');
} else {
hint.replaceChildren();
}
};
if (state.interval) clearInterval(state.interval);
state.interval = setInterval(tick, 250);
clockEl.className = 'clock' + (state.startedAt ? ' run' : '');
tick();
const startBtn = $('button', {
class: 'btn',
text: state.startedAt ? 'Restart timer' : 'Start roast',
onclick: () => {
state.startedAt = Date.now();
state.events = [];
drawMarks();
tick();
startBtn.textContent = 'Restart timer';
},
});
const save = async (andFinish) => {
err.replaceChildren();
const values = form.read();
if (!values.bean) {
err.replaceChildren($('div', { class: 'err', text: 'Give the bean a name.' }));
return;
}
const drop = state.events.find((e) => e.kind === 'drop');
const record = {
...values,
events: state.events.slice().sort((a, b) => a.at - b.at),
totalTime: drop ? drop.at : state.startedAt ? elapsed() : undefined,
roastedAt: new Date(state.startedAt || Date.now()).toISOString(),
createdAt: new Date().toISOString(),
};
try {
const rkey = newKey();
await saveRoast(rkey, record);
clearInterval(state.interval);
TIMER = null;
await loadRoasts();
go(null, andFinish ? '/r/' + rkey : '/');
} catch (e) {
err.replaceChildren($('div', { class: 'err', text: e.message }));
}
};
const back = $('nav', { class: 'back' }, [
$('a', { href: '/', text: '\\u2190 All roasts', onclick: (e) => go(e, '/') }),
]);
paint(
app,
back,
err,
$('div', { class: 'card' }, [
$('div', { class: 'timer' }, [
clockEl,
hint,
$('div', { class: 'row', style: 'justify-content:center;margin-top:14px' }, [startBtn]),
marks,
]),
]),
$('h2', { text: 'The batch' }),
$('div', { class: 'card' }, [...form.setup]),
$('div', { class: 'row', style: 'margin-top:16px' }, [
$('button', { class: 'btn', text: 'Save roast', onclick: () => save(true) }),
$('button', { class: 'btn ghost', text: 'Discard', onclick: () => { clearInterval(state.interval); TIMER = null; go(null, '/'); } }),
]),
footer(),
);
}
function renderDetail(app, rkey) {
const r = ROASTLIST.find((x) => x.rkey === rkey);
if (!r) return renderList(app);
document.title = (r.bean || 'Roast') + ' \\u00b7 Roasts';
const loss = lossOf(r);
const level = levelOf(loss);
const dtr = dtrOf(r);
const total = r.totalTime || eventAt(r, 'drop');
const fc = eventAt(r, 'firstCrackStart');
const err = $('div', {});
const back = $('nav', { class: 'back' }, [
$('a', { href: '/', text: '\\u2190 All roasts', onclick: (e) => go(e, '/') }),
]);
const stats = $('div', { class: 'stats' }, [
$('div', { class: 'stat' }, [
$('div', { class: 'v', text: loss === null ? '–' : loss.toFixed(1) + '%' }),
$('div', { class: 'k', text: 'Weight loss' }),
]),
$('div', { class: 'stat' }, [
$('div', { class: 'v', text: clock(total) }),
$('div', { class: 'k', text: 'Total time' }),
]),
$('div', { class: 'stat' }, [
$('div', { class: 'v', text: clock(fc) }),
$('div', { class: 'k', text: 'First crack' }),
]),
$('div', { class: 'stat' }, [
$('div', { class: 'v', text: dtr === null ? '–' : dtr.toFixed(0) + '%' }),
$('div', { class: 'k', text: 'Development' }),
]),
]);
const meta = [];
if (r.greenWeight) meta.push(r.greenWeight + 'g green');
if (r.roastedWeight) meta.push(r.roastedWeight + 'g roasted');
if (r.machine?.profile) meta.push(r.machine.profile);
if (r.machine?.batchSize) meta.push(r.machine.batchSize);
const head = $('div', {}, [
$('h1', { style: 'margin:0;font-size:23px;letter-spacing:-.02em', text: r.bean || 'Untitled roast' }),
$('div', { class: 'sub dim', style: 'font-size:13.5px;margin-top:4px', text: [fmtDate(r.roastedAt), r.origin].filter(Boolean).join(' \\u00b7 ') }),
level
? $('div', { style: 'margin-top:8px' }, [
$('span', { class: 'level ' + level.cls, text: level.name }),
level.agtron
? $('span', {
class: 'dim',
style: 'font-size:12.5px;margin-left:9px',
text: 'Agtron ' + level.agtron + ' \u00b7 ~' + level.temp + " \u00b7 Sweet Maria's",
})
: null,
])
: null,
]);
const timeline = $('ul', { class: 'tl' });
const evs = (r.events || []).slice().sort((a, b) => a.at - b.at);
let prev = 0;
for (const e of evs) {
timeline.appendChild($('li', {}, [
$('span', { class: 't', text: clock(e.at) }),
$('span', { text: EVENT_LABEL[e.kind] || e.kind }),
$('span', { class: 'd', text: '+' + clock(e.at - prev) }),
]));
prev = e.at;
}
const photos = $('div', { class: 'photos' });
for (const p of r.photos || []) {
photos.appendChild($('a', { href: photoUrl(p), target: '_blank', rel: 'noopener' }, [
$('img', { src: photoUrl(p), alt: '', loading: 'lazy' }),
]));
}
const owner = [];
if (SESSION) {
const weight = $('input', { type: 'number', inputmode: 'decimal', placeholder: 'Roasted weight (g)', value: r.roastedWeight ?? '' });
const notes = $('textarea', { placeholder: 'How did it go? Tasting notes, what to change.' });
notes.value = r.notes || '';
const file = $('input', { type: 'file', accept: 'image/*' });
const saveBtn = $('button', {
class: 'btn',
text: 'Save',
onclick: async () => {
err.replaceChildren();
saveBtn.disabled = true;
try {
const next = { ...r };
delete next.rkey;
next.roastedWeight = weight.value ? Number(weight.value) : undefined;
next.notes = notes.value.trim() || undefined;
if (file.files[0]) {
const blob = await uploadPhoto(file.files[0]);
next.photos = [...(r.photos || []), blob];
}
await saveRoast(rkey, next);
await loadRoasts();
route();
} catch (e) {
err.replaceChildren($('div', { class: 'err', text: e.message }));
saveBtn.disabled = false;
}
},
});
owner.push(
$('h2', { text: 'Finish the log' }),
$('div', { class: 'card' }, [
$('label', { text: 'Roasted weight (g)' }, [weight]),
$('label', { text: 'Notes' }, [notes]),
$('label', { text: 'Add a photo' }, [file]),
$('div', { class: 'row', style: 'margin-top:4px' }, [
saveBtn,
$('button', {
class: 'btn ghost right-end',
text: 'Delete roast',
onclick: async () => {
if (!confirm('Delete this roast?')) return;
try {
await deleteRoast(rkey);
await loadRoasts();
go(null, '/');
} catch (e) {
err.replaceChildren($('div', { class: 'err', text: e.message }));
}
},
}),
]),
]),
);
}
paint(
app,
back,
head,
err,
$('div', { style: 'height:18px' }),
stats,
meta.length ? $('div', { class: 'dim', style: 'font-size:13.5px;margin-bottom:6px', text: meta.join(' \\u00b7 ') }) : null,
evs.length ? $('h2', { text: 'Timeline' }) : null,
evs.length ? $('div', { class: 'card' }, [timeline]) : null,
r.notes ? $('h2', { text: 'Notes' }) : null,
r.notes ? $('div', { class: 'card' }, [$('div', { class: 'note', text: r.notes })]) : null,
(r.photos || []).length ? $('h2', { text: 'Photos' }) : null,
(r.photos || []).length ? photos : null,
...owner,
footer(),
);
}
// ---- routing ----
if ('scrollRestoration' in history) history.scrollRestoration = 'manual';
const POS = new Map();
const path = () => (location.hash.startsWith('#/') ? location.hash.slice(1) : location.pathname);
let rendered = null;
function route() {
const app = document.getElementById('app');
const p = path();
const m = p.match(/^\\/r\\/([^/?#]+)/);
if (m) renderDetail(app, decodeURIComponent(m[1]));
else if (p === '/new') renderNew(app);
else renderList(app);
rendered = p;
}
function go(event, to) {
// Links pass their event; buttons pass only a path.
if (typeof event === 'string') { to = event; event = null; }
if (event && typeof event.preventDefault === 'function') event.preventDefault();
POS.set(path(), window.scrollY);
history.pushState({ app: true }, '', to);
route();
window.scrollTo(0, 0);
}
window.addEventListener('popstate', () => {
if (rendered !== null) POS.set(rendered, window.scrollY);
route();
window.scrollTo(0, POS.get(path()) || 0);
});
discover()
.then(loadRoasts)
.then(route)
.catch((e) => {
document.getElementById('app').replaceChildren(
$('div', { class: 'err', text: 'Could not load: ' + e.message }),
);
});
</script>
</body>
</html>
`;
// ---- deploy ----
const upload = await fetch(`${PDS}/xrpc/com.atproto.repo.uploadBlob`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'text/html; charset=utf-8' },
body: HTML,
});
const blob = (await upload.json()).blob;
await put(SITES, SITE, {
files: [
{
path: 'index.html',
blob,
contentType: 'text/html; charset=utf-8',
cacheControl: 'public, max-age=300',
},
],
fallback: 'index.html',
createdAt: new Date().toISOString(),
});
console.error(
`Deployed ${SITE}: ${Math.round(HTML.length / 1024)}KB, one file.`,
);
// ---- publish it as an installable app ----
const ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#12100e"/>
<path d="M18 22h22v16a11 11 0 0 1-11 11h0a11 11 0 0 1-11-11V22z" fill="#c98a4b"/>
<path d="M40 26h4a6 6 0 0 1 0 12h-4" fill="none" stroke="#c98a4b" stroke-width="3.5"/>
<path d="M24 14c0 3-3 3-3 6M31 13c0 3-3 3-3 6M38 14c0 3-3 3-3 6" fill="none" stroke="#6b625b" stroke-width="3" stroke-linecap="round"/>
</svg>`;
const iconUp = await fetch(`${PDS}/xrpc/com.atproto.repo.uploadBlob`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'image/svg+xml' },
body: ICON,
});
const icon = (await iconUp.json()).blob;
const deployed = await xrpc(
`com.atproto.repo.getRecord?repo=${DID}&collection=${SITES}&rkey=${SITE}`,
);
await put('dev.pdsjs.app.manifest', 'roasts', {
name: 'Roasts',
description:
'A coffee roast log kept in your own repo: a live roast timer that marks dry end, first crack and drop, weight-loss and development-time metrics, Behmor profile and batch settings, notes and photos.',
icon,
site: { uri: deployed.uri, cid: deployed.cid },
suggestedSite: 'roasts',
primitives: ['serve', 'query'],
external: false,
queries: [
{
name: 'coffee-roasts',
collection: ROASTS,
sort: { field: 'roastedAt', direction: 'desc' },
},
],
createdAt: new Date().toISOString(),
});
console.error('Published dev.pdsjs.app.manifest/roasts.');