~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
894 lines · 34.6 KB Raw History
// 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.');