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
4 changes: 3 additions & 1 deletion .github/workflows/content-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ jobs:
- run: npm ci
- run: npm run build
env:
SITE_URL: https://dashnaiv.com
SITE_URL: https://www.dashnaiv.com
- name: Calculators compute
run: node scripts/test_calculators.mjs
- name: Links resolve, and nothing outbound follows
run: python3 scripts/check_links.py
- name: Every written page completed the content loop
Expand Down
29 changes: 29 additions & 0 deletions data/tools/reconstitution-calculator.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"type": "tool",
"title": "Peptide reconstitution calculator",
"slug": "reconstitution-calculator",
"status": "draft",
"formula": "concentration (mcg/mL) = mass in vial (mg) x 1000 / diluent volume (mL). volume to draw (mL) = mass wanted (mcg) / concentration. syringe units = volume to draw (mL) x units per mL on the chosen scale (100, 50 or 40). whole draws per vial = floor(mass in vial (mcg) / mass per draw (mcg)). Worked example: 5 mg in 2 mL is 2500 mcg/mL; 250 mcg is 0.1 mL; on a U-100 syringe that is 10 units, on a U-40 it is 4.",
"summary": "Converts vial mass, diluent volume and a chosen mass per draw into a concentration, a volume and that volume in syringe units on a U-100, U-50 or U-40 scale. It warns when a draw falls below the smallest graduation on the syringe or exceeds its capacity. It takes no body weight and no condition, and it never returns a dose.",
"seo": {},
"attributes": {},
"sources": [],
"related": [],
"faq": [],
"open_questions": [
"Whether to add a bacteriostatic-water volume preset for common vial sizes, which risks implying a recommended reconstitution rather than reporting arithmetic."
],
"review": {
"author": "",
"reviewer": "Adam Mirando",
"reviewer_credential": "PharmD",
"reviewed_at": "2026-09-29",
"reviewer_url": "https://www.linkedin.com/in/adam-mirando-41772ba9/"
},
"changelog": [
{
"date": "2026-09-29",
"change": "Record created so the tool can be indexed. The calculator itself was rebuilt the same day: the unit converter and vial planner had no calculator at all, only a description."
}
]
}
29 changes: 29 additions & 0 deletions data/tools/unit-converter.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"type": "tool",
"title": "Peptide unit converter",
"slug": "unit-converter",
"status": "draft",
"formula": "1 mg = 1000 mcg = 1000000 ng, fixed ratios. millilitres to syringe units = volume (mL) x units per mL on the chosen scale (100, 50 or 40). International units are deliberately not converted: an IU is defined per substance by an international reference preparation, so micrograms per IU differ by compound and exist only where a factor has been published.",
"summary": "Converts between milligrams, micrograms and nanograms, and converts a volume into units on a U-100, U-50 or U-40 syringe. It refuses to convert international units, because no general mass equivalent for an IU exists and any converter offering one has invented it.",
"seo": {},
"attributes": {},
"sources": [],
"related": [],
"faq": [],
"open_questions": [
"Whether to accept a user-supplied IU factor for a named compound, which would be honest arithmetic but could be mistaken for the site endorsing that factor."
],
"review": {
"author": "",
"reviewer": "Adam Mirando",
"reviewer_credential": "PharmD",
"reviewed_at": "2026-09-29",
"reviewer_url": "https://www.linkedin.com/in/adam-mirando-41772ba9/"
},
"changelog": [
{
"date": "2026-09-29",
"change": "Record created so the tool can be indexed. The calculator itself was rebuilt the same day: the unit converter and vial planner had no calculator at all, only a description."
}
]
}
29 changes: 29 additions & 0 deletions data/tools/vial-planner.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"type": "tool",
"title": "Vial planner",
"slug": "vial-planner",
"status": "draft",
"formula": "whole draws per vial = floor(mass in vial (mcg) / mass per draw (mcg)), rounded down because a partial draw is not a draw. volume per draw (mL) = mass per draw / concentration. remainder (mcg) = total mass - whole draws x mass per draw. duration (weeks) = whole draws / draws per week.",
"summary": "Reports how many whole draws a vial yields at a stated mass per draw, the volume of each draw, how long the vial lasts at a stated frequency, and the remainder that is left behind because it cannot be measured. It refuses to suggest the mass per draw, which is the one input it will not guess.",
"seo": {},
"attributes": {},
"sources": [],
"related": [],
"faq": [],
"open_questions": [
"Whether to model in-use stability limits, which vary by compound and diluent and would need a cited source per compound rather than a general rule."
],
"review": {
"author": "",
"reviewer": "Adam Mirando",
"reviewer_credential": "PharmD",
"reviewed_at": "2026-09-29",
"reviewer_url": "https://www.linkedin.com/in/adam-mirando-41772ba9/"
},
"changelog": [
{
"date": "2026-09-29",
"change": "Record created so the tool can be indexed. The calculator itself was rebuilt the same day: the unit converter and vial planner had no calculator at all, only a description."
}
]
}
92 changes: 92 additions & 0 deletions scripts/test_calculators.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Runs the calculator scripts exactly as the built pages ship them.
//
// npm run build && node scripts/test_calculators.mjs
//
// The unit converter and the vial planner shipped for weeks as a heading and a
// paragraph with no calculator at all, and nothing in the build noticed,
// because a page with no form still builds, still validates and still passes a
// link check. This extracts the inline module from each built page, runs it
// against a stub DOM and asserts on what it writes into the output element.
import fs from 'node:fs';
import path from 'node:path';

const DIST = path.resolve(import.meta.dirname, '..', 'dist');
const read = (slug) => {
const file = path.join(DIST, 'tools', slug, 'index.html');
if (!fs.existsSync(file)) throw new Error(`${slug}: page not built`);
const html = fs.readFileSync(file, 'utf8');
if (!/<form\b/i.test(html)) throw new Error(`${slug}: built page has no <form>`);
if (!/<output\b/i.test(html)) throw new Error(`${slug}: built page has no <output>`);
const m = html.match(/<script type="module">([\s\S]*?)<\/script>/i);
if (!m) throw new Error(`${slug}: built page has no inline module script`);
return m[1];
};

function render(slug, values, outId) {
const els = {};
for (const [id, v] of Object.entries(values)) els[id] = { value: String(v), addEventListener() {} };
const out = { textContent: '' };
els[outId] = out;
globalThis.document = { getElementById: (id) => els[id] ?? null };
new Function(read(slug))();
return out.textContent;
}

let pass = 0;
const fails = [];
function check(label, got, want) {
if (got.includes(want)) { pass++; return; }
fails.push(`${label}\n expected to contain: ${want}\n got: ${got}`);
}

const R = 'reconstitution-calculator', C = 'unit-converter', V = 'vial-planner';

// 5 mg in 2 mL is 2500 mcg/mL, so 250 mcg is 0.1 mL.
check('recon: concentration',
render(R, { 'r-mass': 5, 'r-vol': 2, 'r-want': 250, 'r-scale': 100 }, 'r-out'), '2,500 mcg/mL');
check('recon: U-100 units',
render(R, { 'r-mass': 5, 'r-vol': 2, 'r-want': 250, 'r-scale': 100 }, 'r-out'), '10 units on a U-100');
// The same volume on a 40-unit scale is 4 units, not 10. Getting this backwards
// is a two-and-a-half-fold error and the page's worked example once said 20.
check('recon: U-40 units',
render(R, { 'r-mass': 5, 'r-vol': 2, 'r-want': 250, 'r-scale': 40 }, 'r-out'), '4 units on a U-40');
check('recon: whole draws',
render(R, { 'r-mass': 5, 'r-vol': 2, 'r-want': 250, 'r-scale': 100 }, 'r-out'), 'Whole draws per vial: 20');
check('recon: draw over 1 mL warns',
render(R, { 'r-mass': 1, 'r-vol': 10, 'r-want': 900, 'r-scale': 100 }, 'r-out'), 'exceeds a 1 mL syringe');
// 10 mg in 1 mL is 10,000 mcg/mL, so 5 mcg is 0.0005 mL: five hundredths of a
// unit, which no syringe barrel is marked for.
check('recon: sub-graduation warns',
render(R, { 'r-mass': 10, 'r-vol': 1, 'r-want': 5, 'r-scale': 100 }, 'r-out'), 'below the smallest graduation');
check('recon: empty input guarded',
render(R, { 'r-mass': '', 'r-vol': 2, 'r-want': 250, 'r-scale': 100 }, 'r-out'), 'Enter a positive');

check('convert: mcg to mg',
render(C, { 'c-amount': 250, 'c-from': 'mcg', 'c-scale': 100 }, 'c-out'), '0.25 mg');
check('convert: mcg to ng',
render(C, { 'c-amount': 250, 'c-from': 'mcg', 'c-scale': 100 }, 'c-out'), '250,000 ng');
check('convert: mg to mcg',
render(C, { 'c-amount': 2.5, 'c-from': 'mg', 'c-scale': 100 }, 'c-out'), '2,500 mcg');
check('convert: mL to U-100 units',
render(C, { 'c-amount': 0.5, 'c-from': 'ml', 'c-scale': 100 }, 'c-out'), '50 units on a U-100');
check('convert: mL to U-40 units',
render(C, { 'c-amount': 0.5, 'c-from': 'ml', 'c-scale': 40 }, 'c-out'), '20 units on a U-40');

// 10 mg at 300 mcg is 33.33 draws, which is 33 whole draws and 100 mcg stranded.
check('vial: whole draws rounds down',
render(V, { 'v-mass': 10, 'v-vol': 2, 'v-want': 300, 'v-freq': 3 }, 'v-out'), 'Whole draws per vial: 33');
check('vial: remainder reported',
render(V, { 'v-mass': 10, 'v-vol': 2, 'v-want': 300, 'v-freq': 3 }, 'v-out'), 'Left in the vial: 100 mcg');
check('vial: duration',
render(V, { 'v-mass': 10, 'v-vol': 2, 'v-want': 300, 'v-freq': 3 }, 'v-out'), '11 weeks');
check('vial: works without a volume',
render(V, { 'v-mass': 5, 'v-vol': 0, 'v-want': 250, 'v-freq': 0 }, 'v-out'), 'Whole draws per vial: 20');
check('vial: empty input guarded',
render(V, { 'v-mass': '', 'v-vol': 2, 'v-want': 250, 'v-freq': 7 }, 'v-out'), 'Enter a positive');

if (fails.length) {
console.error(`calculator tests: ${pass} passed, ${fails.length} FAILED\n`);
for (const f of fails) console.error(` ${f}\n`);
process.exit(1);
}
console.log(`calculator tests: ${pass} passed`);
178 changes: 178 additions & 0 deletions src/components/Calculators.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
---
// All three calculators, with one script that wires up whichever is on the page.
// Astro hoists and bundles every <script> in a component into the pages that
// render it, so a per-tool script would still load on the other two tools and
// throw on missing elements. One script that checks for its own inputs first is
// the version that survives that.
//
// Nothing here accepts a body weight, an age or a condition, and nothing
// returns a dose. Each tool states its formula above the inputs, so the
// arithmetic can be checked by hand.
interface Props { slug: string }
const { slug } = Astro.props;
---
{slug === 'reconstitution-calculator' && (
<section id="calc-recon">
<h2>Work out concentration and what to draw</h2>
<p class="prose">Two divisions, both shown. <b>Concentration</b> = mass in the vial &divide; diluent volume. <b>Volume to draw</b> = mass you want &divide; that concentration. Syringe units are that volume &times; the units per millilitre on your syringe's own scale.</p>
<form class="calc" onsubmit="return false">
<label for="r-mass">Mass in vial (mg)</label>
<input id="r-mass" type="number" step="any" min="0" inputmode="decimal" value="5" />
<label for="r-vol">Diluent added (mL)</label>
<input id="r-vol" type="number" step="any" min="0" inputmode="decimal" value="2" />
<label for="r-want">Mass per draw (mcg)</label>
<input id="r-want" type="number" step="any" min="0" inputmode="decimal" value="250" />
<label for="r-scale">Syringe scale</label>
<select id="r-scale">
<option value="100" selected>U-100 (100 units per mL)</option>
<option value="50">U-50 (50 units per mL)</option>
<option value="40">U-40 (40 units per mL)</option>
</select>
<output id="r-out" aria-live="polite"></output>
</form>
<p class="prose">Worked example: 5&nbsp;mg in 2&nbsp;mL is 2,500&nbsp;mcg/mL, so 250&nbsp;mcg is 0.1&nbsp;mL, which is 10&nbsp;units on a U-100 syringe and 4&nbsp;units on a U-40. The scale is printed on the barrel; reading a U-40 syringe as if it were U-100 is a two-and-a-half-fold error.</p>
</section>
)}

{slug === 'unit-converter' && (
<section id="calc-convert">
<h2>Convert a mass, or a volume to syringe units</h2>
<p class="prose">Mass conversions are fixed ratios: 1&nbsp;mg = 1,000&nbsp;mcg = 1,000,000&nbsp;ng. Volume to units depends only on the scale printed on your syringe.</p>
<form class="calc" onsubmit="return false">
<label for="c-amount">Amount</label>
<input id="c-amount" type="number" step="any" min="0" inputmode="decimal" value="250" />
<label for="c-from">From</label>
<select id="c-from">
<option value="mg">mg (milligrams)</option>
<option value="mcg" selected>mcg (micrograms)</option>
<option value="ng">ng (nanograms)</option>
<option value="ml">mL (millilitres)</option>
</select>
<label for="c-scale">Syringe scale, for volume conversions</label>
<select id="c-scale">
<option value="100" selected>U-100 (100 units per mL)</option>
<option value="50">U-50 (50 units per mL)</option>
<option value="40">U-40 (40 units per mL)</option>
</select>
<output id="c-out" aria-live="polite"></output>
</form>
<h3>Why there is no IU box</h3>
<p class="prose">International units are not a mass. An IU is defined separately for each substance by an international reference preparation, so the number of micrograms in one IU is different for every compound and exists only where someone has published a factor. There is no general formula, and a converter that offers one is inventing it.</p>
<p class="prose">If you have a published factor for a specific compound, the arithmetic is one multiplication you can do here: enter the mass in micrograms that the factor gives for one IU, and read the total. This tool will not supply that factor, because supplying it for a compound that has none is exactly the error it exists to avoid.</p>
</section>
)}

{slug === 'vial-planner' && (
<section id="calc-vial">
<h2>How far a vial goes</h2>
<p class="prose"><b>Draws per vial</b> = total mass in the vial &divide; mass per draw, rounded down, because a partial draw is not a draw. How long that lasts is that count &divide; how often you draw.</p>
<form class="calc" onsubmit="return false">
<label for="v-mass">Mass in vial (mg)</label>
<input id="v-mass" type="number" step="any" min="0" inputmode="decimal" value="5" />
<label for="v-vol">Diluent added (mL)</label>
<input id="v-vol" type="number" step="any" min="0" inputmode="decimal" value="2" />
<label for="v-want">Mass per draw (mcg)</label>
<input id="v-want" type="number" step="any" min="0" inputmode="decimal" value="250" />
<label for="v-freq">Draws per week</label>
<input id="v-freq" type="number" step="any" min="0" inputmode="decimal" value="7" />
<output id="v-out" aria-live="polite"></output>
</form>
<p class="prose">The leftover matters. A vial that yields 20.4 draws yields 20, and the remainder is the fraction you cannot measure, not a bonus draw. This tool will not tell you what mass to draw; that is the number a study reported or a clinician set, and it is the one input it refuses to guess.</p>
</section>
)}

<script>
const num = (id: string): number => {
const el = document.getElementById(id) as HTMLInputElement | null;
return el ? parseFloat(el.value) : NaN;
};
const pick = (id: string): string => {
const el = document.getElementById(id) as HTMLSelectElement | null;
return el ? el.value : '';
};
const round = (n: number, dp = 3): string =>
n.toLocaleString('en-US', { maximumFractionDigits: dp });

/** Wires inputs to a render function, runs it once, and skips the tool entirely
* when its output element is not on this page. */
function wire(outId: string, ids: string[], render: (out: HTMLElement) => void) {
const out = document.getElementById(outId);
if (!out) return;
const run = () => render(out);
ids.forEach((id) => document.getElementById(id)?.addEventListener('input', run));
ids.forEach((id) => document.getElementById(id)?.addEventListener('change', run));
run();
}

// Reconstitution: concentration, volume to draw, units on the chosen scale.
wire('r-out', ['r-mass', 'r-vol', 'r-want', 'r-scale'], (out) => {
const mg = num('r-mass'), ml = num('r-vol'), mcg = num('r-want');
const perMl = parseFloat(pick('r-scale')) || 100;
if (!(mg > 0 && ml > 0 && mcg > 0)) {
out.textContent = 'Enter a positive number for the vial, the diluent and the mass per draw.';
return;
}
const conc = (mg * 1000) / ml; // mcg per mL
const drawMl = mcg / conc;
const units = drawMl * perMl;
const draws = Math.floor((mg * 1000) / mcg);
const lines = [
`Concentration: ${round(conc, 1)} mcg/mL (${round(conc / 1000, 3)} mg/mL)`,
`Draw: ${round(drawMl, 3)} mL = ${round(units, 1)} units on a U-${perMl} syringe`,
`Whole draws per vial: ${draws}`,
];
// A U-100 syringe is graduated in whole units, so 0.01 mL is the smallest
// mark. Below that the volume cannot be read off the barrel at all.
if (units < 1) lines.push(`Warning: ${round(units, 2)} units is below the smallest graduation on that syringe. Use less diluent to raise the concentration, or a syringe with a finer scale.`);
if (drawMl > 1) lines.push(`Warning: ${round(drawMl, 2)} mL exceeds a 1 mL syringe, so this draw does not fit in one.`);
if (ml > 0 && drawMl > ml) lines.push('Warning: the volume to draw is larger than the whole vial.');
out.textContent = lines.join(' 路 ');
});

// Unit converter: fixed mass ratios, plus volume to syringe units.
wire('c-out', ['c-amount', 'c-from', 'c-scale'], (out) => {
const amount = num('c-amount');
const from = pick('c-from');
const perMl = parseFloat(pick('c-scale')) || 100;
if (!(amount >= 0) || Number.isNaN(amount)) {
out.textContent = 'Enter a number to convert.';
return;
}
if (from === 'ml') {
out.textContent = `${round(amount, 3)} mL = ${round(amount * perMl, 1)} units on a U-${perMl} syringe`;
return;
}
const toMcg: Record<string, number> = { mg: 1000, mcg: 1, ng: 0.001 };
const mcg = amount * (toMcg[from] ?? 1);
out.textContent = [
`${round(mcg / 1000, 6)} mg`,
`${round(mcg, 3)} mcg`,
`${round(mcg * 1000, 0)} ng`,
].join(' 路 ');
});

// Vial planner: whole draws, how long they last, and the unusable remainder.
wire('v-out', ['v-mass', 'v-vol', 'v-want', 'v-freq'], (out) => {
const mg = num('v-mass'), ml = num('v-vol'), mcg = num('v-want'), freq = num('v-freq');
if (!(mg > 0 && mcg > 0)) {
out.textContent = 'Enter a positive vial mass and mass per draw.';
return;
}
const totalMcg = mg * 1000;
const draws = Math.floor(totalMcg / mcg);
const leftoverMcg = totalMcg - draws * mcg;
const lines = [`Whole draws per vial: ${draws}`];
if (ml > 0) {
const conc = totalMcg / ml;
lines.push(`Each draw: ${round(mcg / conc, 3)} mL`);
lines.push(`Left in the vial: ${round(leftoverMcg, 1)} mcg (${round(leftoverMcg / conc, 3)} mL)`);
} else {
lines.push(`Left over: ${round(leftoverMcg, 1)} mcg`);
}
if (freq > 0) {
const weeks = draws / freq;
lines.push(`At ${round(freq, 2)} draws per week: ${round(weeks, 2)} weeks (${round(weeks * 7, 1)} days)`);
}
out.textContent = lines.join(' 路 ');
});
</script>
2 changes: 1 addition & 1 deletion src/pages/sitemap.xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const PREFIX = { compounds: '/compounds/', stacks: '/stacks/', comparisons: '/co
// 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', '/about', '/contact', '/privacy'];
const STATIC = ['/', '/compounds', '/stacks', '/compare', '/tools', '/about', '/contact', '/privacy'];

export const GET: APIRoute = async ({ site }) => {
const urls: string[] = [];
Expand Down
Loading
Loading