Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions scripts/test_calculators.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,43 @@ check('vial: works without a volume',
check('vial: empty input guarded',
render(V, { 'v-mass': '', 'v-vol': 2, 'v-want': 250, 'v-freq': 7 }, 'v-out'), 'Enter a positive');

// Every per-compound calculator runs the same script, so one broken render or a
// template change that drops the form would take all of them out at once and
// nothing else in the build would notice: a page with no <form> still builds,
// still validates and still enters the sitemap. That is exactly how the unit
// converter and vial planner shipped empty.
const CALC_DIR = path.join(DIST, 'calculators');
if (fs.existsSync(CALC_DIR)) {
const slugs = fs.readdirSync(CALC_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
if (slugs.length === 0) fails.push('dist/calculators exists but contains no compound pages');
for (const slug of slugs) {
const file = path.join(CALC_DIR, slug, 'index.html');
if (!fs.existsSync(file)) { fails.push(`calculators/${slug}: not built`); continue; }
const html = fs.readFileSync(file, 'utf8');
if (!/<form\b/i.test(html)) { fails.push(`calculators/${slug}: no <form>`); continue; }
if (!/<output\b/i.test(html)) { fails.push(`calculators/${slug}: no <output>`); continue; }
// The dose table is the only reason this page exists rather than a link to
// the generic tool, so an empty one means the page should not have shipped.
const tableRows = (html.match(/<tbody>[\s\S]*?<\/tbody>/i) || [''])[0].match(/<tr>/gi) || [];
if (tableRows.length === 0) fails.push(`calculators/${slug}: dose table is empty`);
const m = html.match(/<script type="module">([\s\S]*?)<\/script>/i);
if (!m) { fails.push(`calculators/${slug}: no inline module script`); continue; }
const els = {};
for (const [id, v] of Object.entries({ 'r-mass': 5, 'r-vol': 2, 'r-want': 250, 'r-scale': 100 })) {
els[id] = { value: String(v), addEventListener() {} };
}
const out = { textContent: '' };
els['r-out'] = out;
globalThis.document = { getElementById: (id) => els[id] ?? null };
new Function(m[1])();
if (!out.textContent.includes('10 units on a U-100')) {
fails.push(`calculators/${slug}: calculator did not compute (got ${JSON.stringify(out.textContent)})`);
} else { pass++; }
}
}

if (fails.length) {
console.error(`calculator tests: ${pass} passed, ${fails.length} FAILED\n`);
for (const f of fails) console.error(` ${f}\n`);
Expand Down
59 changes: 59 additions & 0 deletions src/lib/calculators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Which compounds get their own calculator page, and the dose figures that make
// each one different from the generic tool.
//
// A calculator per compound is only worth a URL if the page says something the
// generic reconstitution calculator does not. The arithmetic is identical for
// every peptide, so the arithmetic cannot be the differentiator. What differs is
// the doses that published studies actually used, which this site already holds
// as cited claims on each record.
//
// So a compound qualifies only when it carries at least two distinct quoted dose
// figures, and only when its own record is finished and reviewed. Everything
// else would be the same page with the name changed, which is how a programmatic
// set turns into index bloat.
import type { CollectionEntry } from 'astro:content';
import { isIndexable } from './site';

export const MIN_DISTINCT_DOSES = 2;

const DOSE_FIGURE = /\d+(?:[.,]\d+)?\s*(?:mg|µg|mcg|ug|ng|IU|units?)\b/gi;

export interface DoseRow {
value: string;
tier: string;
sourceIds: string[];
excerpt?: string;
}

/** Distinct dose figures quoted anywhere in the record's study_doses claims. */
export function doseFigures(entry: CollectionEntry<'compounds'>): string[] {
const claims = ((entry.data as any).attributes?.study_doses ?? []) as any[];
const seen = new Set<string>();
for (const c of claims) {
for (const m of String(c.value ?? '').matchAll(DOSE_FIGURE)) {
seen.add(m[0].replace(/\s+/g, ' ').trim().toLowerCase());
}
}
return [...seen];
}

export function doseRows(entry: CollectionEntry<'compounds'>): DoseRow[] {
const claims = ((entry.data as any).attributes?.study_doses ?? []) as any[];
return claims.map((c) => ({
value: String(c.value ?? ''),
tier: String(c.evidence_label ?? 'editorial'),
sourceIds: (c.source_ids ?? []) as string[],
excerpt: c.source_excerpt,
}));
}

/** True when a compound earns its own calculator page. */
export function qualifies(entry: CollectionEntry<'compounds'>): boolean {
return isIndexable(entry) && doseFigures(entry).length >= MIN_DISTINCT_DOSES;
}

/** Human-tier dose claims, which are the ones worth leading with. */
export const HUMAN_TIERS = new Set(['approved-label', 'human-clinical-trial', 'observational-human']);
export function humanDoseRows(entry: CollectionEntry<'compounds'>): DoseRow[] {
return doseRows(entry).filter((r) => HUMAN_TIERS.has(r.tier));
}
104 changes: 104 additions & 0 deletions src/pages/calculators/[slug].astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
---
// One calculator per compound. The arithmetic is the same on every one of these
// pages, so the arithmetic is not what makes them different: the doses that
// published studies of that compound actually used are, and those are quoted
// from the record with their evidence tier and their source.
//
// Nothing here takes a body weight or a condition, and nothing returns a dose.
// Showing what a trial administered is reporting, which is what the whole site
// does; suggesting what a reader should take would be advice, which it never does.
import Base from '../../layouts/Base.astro';
import Calculators from '../../components/Calculators.astro';
import Tier from '../../components/v2/Tier.astro';
import { records } from '../../lib/collections';
import { qualifies, doseRows, humanDoseRows, doseFigures } from '../../lib/calculators';
import { isIndexable, TIER_LABEL } from '../../lib/site';

export async function getStaticPaths() {
const compounds = await records('compounds');
return compounds.filter(qualifies).map((entry) => ({
params: { slug: entry.data.slug },
props: { entry },
}));
}
const { entry } = Astro.props;
const d = entry.data as any;
const name = d.preferred_name;
const path = `/calculators/${d.slug}`;
const rows = doseRows(entry);
const human = humanDoseRows(entry);
const figures = doseFigures(entry);
const sources = new Map<string, any>((d.sources ?? []).map((s: any) => [s.id, s]));
const routes = (d.attributes?.routes_studied ?? []) as any[];

const title = `${name} Dosage Calculator: mg to mL and Syringe Units`;
const description = `Convert ${name} vial mass and diluent into concentration, draw volume and syringe units, beside the doses published studies of ${name} actually used, each one cited.`;
---
<Base title={title} description={description} path={path} indexable={isIndexable(entry)} siteSuffix={false}>
<article class="prose-page">
<p class="eyebrow"><a href="/calculators">Calculators</a></p>
<h1>{name} dosage calculator</h1>
<p class="dek">
The arithmetic is the same for every peptide, so the part worth reading is underneath it:
the {human.length > 0 ? 'doses' : 'figures'} that published studies of {name} actually used,
quoted and tiered. This tool converts the numbers you enter. It does not suggest what to take.
</p>

<Calculators slug="reconstitution-calculator" />

<h2 id="studied">Doses in published studies of {name}</h2>
<p class="prose">
Every row is quoted from a paper in this compound's own source ledger and carries the kind of
evidence it came from. {human.length === 0
? `None of them is a human dose: no study in this ledger administered ${name} to people at a stated dose. Animal figures are given per kilogram of animal and do not convert to a person by arithmetic.`
: `${human.length} of ${rows.length} come from human studies. The rest are animal or laboratory work, where a per-kilogram figure does not convert to a person.`}
</p>
<div class="card clip"><div class="tblwrap"><table class="tbl">
<caption>What studies of {name} administered, as published. Not a recommendation and not a range to pick from.</caption>
<thead><tr><th scope="col">Evidence</th><th scope="col">Dose as the paper states it</th><th scope="col">Source</th></tr></thead>
<tbody>
{rows.map((r) => (
<tr>
<td><Tier tier={r.tier} /></td>
<td>{r.value}</td>
<td>{r.sourceIds.map((id) => {
const s = sources.get(id);
return s?.url
? <a href={s.url} rel="nofollow noopener">{id}</a>
: <span>{id}</span>;
})}</td>
</tr>
))}
</tbody>
</table></div></div>

{routes.length > 0 && (
<Fragment>
<h2 id="routes">How {name} was given in those studies</h2>
<p class="prose">The route changes what a dose means. A figure from an intravenous infusion is not the same quantity as the same number given under the skin.</p>
<ul class="list">{routes.slice(0, 6).map((r: any) => <li>{r.value}</li>)}</ul>
</Fragment>
)}

<h2 id="refuses">What this calculator will not do</h2>
<p class="prose">
It will not take a body weight and return a milligram figure. That is a clinical decision, not
a division: it depends on why someone is taking something, what else they take, and what their
kidneys and liver are doing. None of that is knowable from a number typed into a box.
</p>
<p class="prose">
The table above is the published record, not a menu. {human.length === 0
? `For ${name} in particular, there is no human dose in this ledger to copy even if you wanted to.`
: `A dose that appeared in a trial appeared under supervision, with monitoring, in people selected to be in that trial.`}
The full evidence, including what was measured and what went wrong, is on the <a href={`/compounds/${d.slug}`}>{name} record</a>.
</p>

<h2 id="other">Other calculators</h2>
<ul class="cards">
<li><a href="/tools/reconstitution-calculator">Generic reconstitution calculator</a><div class="sub">The same arithmetic, without a compound attached</div></li>
<li><a href="/tools/unit-converter">Unit converter</a><div class="sub">mg, mcg, ng and syringe units</div></li>
<li><a href="/tools/vial-planner">Vial planner</a><div class="sub">How many draws a vial yields and how long it lasts</div></li>
<li><a href="/calculators">All compound calculators</a><div class="sub">Every compound with published dose figures</div></li>
</ul>
</article>
</Base>
67 changes: 67 additions & 0 deletions src/pages/calculators/index.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
import Base from '../../layouts/Base.astro';
import Tier from '../../components/v2/Tier.astro';
import { records } from '../../lib/collections';
import { qualifies, doseFigures, humanDoseRows } from '../../lib/calculators';
import { tools } from '../../lib/registry';

const compounds = (await records('compounds'))
.filter(qualifies)
.sort((a, b) => a.data.preferred_name.localeCompare(b.data.preferred_name));
const live = compounds.length > 0;
---
<Base
title="Peptide Dosage Calculators, One Per Compound"
description="A reconstitution calculator for each compound with published dose figures, showing the doses studies actually used beside the arithmetic, every one cited."
path="/calculators" indexable={live} siteSuffix={false}>
<article class="prose-page">
<p class="eyebrow">Directory</p>
<h1>Peptide dosage calculators</h1>
<p class="dek">
One calculator per compound, each showing the doses published studies of that compound
actually used, quoted with the kind of evidence they came from. The arithmetic is identical
everywhere; the evidence is not, and that is the part worth reading.
</p>

<h2 id="compounds">By compound</h2>
<p class="prose">
{compounds.length} compounds carry enough published dose figures to be worth their own page.
A compound with no cited dose does not get one, because it would be this page with a different
name on it.
</p>
<div class="card clip"><div class="tblwrap"><table class="tbl">
<caption>Each calculator does the same arithmetic. The difference is the evidence underneath it.</caption>
<thead><tr><th scope="col">Compound</th><th scope="col">Strongest dose evidence</th><th scope="col" class="num">Dose figures</th><th scope="col" class="num">Human doses</th></tr></thead>
<tbody>
{compounds.map((e) => {
const human = humanDoseRows(e);
return (
<tr>
<th scope="row" class="study"><a href={`/calculators/${e.data.slug}`}>{e.data.preferred_name}</a></th>
<td><Tier tier={e.data.evidence_tier} /></td>
<td class="num">{doseFigures(e).length}</td>
<td class="num">{human.length || '—'}</td>
</tr>
);
})}
</tbody>
</table></div></div>

<h2 id="generic">Without a compound attached</h2>
<ul class="cards">
{tools.map((t) => <li><a href={`/tools/${t.slug}`}>{t.name}</a><div class="sub">{t.computes}</div></li>)}
</ul>

<h2 id="refuses">What none of them will do</h2>
<p class="prose">
None takes a body weight, an age or a condition, and none returns a dose. They convert numbers
you already have into other numbers, by a formula printed above the inputs. Whether those
numbers are appropriate is a clinical question, and the <a href="/compounds">compound records</a>
are where the evidence for it lives.
</p>
<p class="prose">
A dose shown on these pages is one a study administered, reported as published. It is a record
of what happened in a trial, not a suggestion about what should happen to you.
</p>
</article>
</Base>
12 changes: 11 additions & 1 deletion src/pages/sitemap.xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@
import type { APIRoute } from 'astro';
import { records } from '../lib/collections';
import { isIndexable, lastmod } from '../lib/site';
import { qualifies } from '../lib/calculators';

const PREFIX = { compounds: '/compounds/', stacks: '/stacks/', comparisons: '/compare/', tools: '/tools/', posts: '/blog/' } as const;

// Rendered pages that are not records. Kept here rather than derived, because
// a page that should not be indexed must not be listed, and that is a decision
// per page rather than a pattern.
const STATIC = ['/', '/compounds', '/stacks', '/compare', '/tools', '/about', '/contact', '/privacy'];
const STATIC = ['/', '/compounds', '/stacks', '/compare', '/tools', '/calculators', '/about', '/contact', '/privacy'];

export const GET: APIRoute = async ({ site }) => {
const urls: string[] = [];
Expand All @@ -29,6 +30,15 @@ export const GET: APIRoute = async ({ site }) => {
urls.push(`<url><loc>${loc}</loc>${mod ? `<lastmod>${mod}</lastmod>` : ''}</url>`);
}
}
// Per-compound calculators are derived from the compounds collection rather
// than being a collection of their own, so nothing above emits them.
for (const entry of await records('compounds')) {
if (!qualifies(entry)) continue;
const loc = new URL(`/calculators/${entry.data.slug}`, site).toString();
const mod = lastmod(entry);
urls.push(`<url><loc>${loc}</loc>${mod ? `<lastmod>${mod}</lastmod>` : ''}</url>`);
}

const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>\n`;
return new Response(xml, { headers: { 'Content-Type': 'application/xml; charset=utf-8' } });
};
Loading