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
9 changes: 8 additions & 1 deletion HANDOVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,14 @@ This document provides a comprehensive inventory of services, secrets, external

### 3. Custom Domain & DNS Migration
- **Current State**: [dream.yuson.au](https://dream.yuson.au) is mapped via DNS to Cloudflare Pages.
- **Succession Risk**: `yuson.au` is a **personal domain** owned by the original author. If the personal domain lapses or becomes unavailable, the custom URL will stop resolving.
- **Succession Risk**: `yuson.au` is a **personal domain** owned by the original author. If the personal
domain lapses or becomes unavailable, the custom URL will stop resolving.
- **BOTH APPLICATIONS SHARE THIS DOMAIN.** SCRATCH, the drug-protocol source of truth, is served at
[scratch.yuson.au](https://scratch.yuson.au) from the same personal domain. If `yuson.au` lapses,
clinicians lose the reference handbook and the app that records the encounter **at the same time**,
and DREAM's `npm run protocols:sync` stops resolving. Treat moving both to an institutional domain
as a single piece of work, not two. The SCRATCH repository is `monchee/drug-library`; its Cloudflare
Pages project is named `scratch`.
- **Domain Independence**: In `vite.config.ts`, `base: './'` is configured, ensuring all bundle assets and routes use relative paths. The application is completely domain-agnostic and functions identically under any hostname, subdirectory, or port.
- **Fallback URL**: [anaesthetic-allergy-log-7ya.pages.dev](https://anaesthetic-allergy-log-7ya.pages.dev)
— **not** `dream.pages.dev`, which does not resolve. A Pages project's `*.pages.dev` hostname is
Expand Down
2 changes: 1 addition & 1 deletion MAINTAINERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Overview

DREAM (Drug Reaction Evaluation & Anaesthetic Management) is a private, local-first clinical Progressive Web App designed for the Royal Prince Alfred Hospital (RPAH) Department of Clinical Immunology & Allergy. It guides clinicians through perioperative anaesthetic allergy workups—from REDCap patient record import and tailored testing plan creation to skin prick/intradermal test logging, patient handouts, and eMR-ready clinical reports—all processed locally in the browser without transmitting identifiable patient data. Drug protocol definitions and dilution concentrations are tied to the SCRATCH drug library (repository `monchee/drug-library`, hosted at [scratch.pages.dev](https://scratch.pages.dev)), which serves as the upcoming source of truth for drug protocol data that DREAM consumes as a pinned JSON snapshot.
DREAM (Drug Reaction Evaluation & Anaesthetic Management) is a private, local-first clinical Progressive Web App designed for the Royal Prince Alfred Hospital (RPAH) Department of Clinical Immunology & Allergy. It guides clinicians through perioperative anaesthetic allergy workups—from REDCap patient record import and tailored testing plan creation to skin prick/intradermal test logging, patient handouts, and eMR-ready clinical reports—all processed locally in the browser without transmitting identifiable patient data. Drug protocol definitions and dilution concentrations are tied to the SCRATCH drug library (repository `monchee/drug-library`, hosted at [scratch.yuson.au](https://scratch.yuson.au)), which serves as the upcoming source of truth for drug protocol data that DREAM consumes as a pinned JSON snapshot.

---

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"prebuild": "node scripts/generate-changelog.mjs",
"build": "vite build",
"changelog:sync": "node scripts/generate-changelog.mjs",
"protocols:sync": "node scripts/sync-protocols.mjs",
"protocols:generate": "node scripts/generate-drug-masterlist.mjs",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint . --ext ts,tsx --fix",
Expand Down
229 changes: 229 additions & 0 deletions scripts/generate-drug-masterlist.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
#!/usr/bin/env node
// Generates src/shared/data/drugMasterlist.generated.ts from
// src/shared/data/protocols.snapshot.json.
//
// Run manually with `npm run protocols:generate`.

import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { dirname, join } from 'node:path';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const SNAPSHOT_PATH = join(ROOT, 'src', 'shared', 'data', 'protocols.snapshot.json');
const OUTPUT_PATH = join(ROOT, 'src', 'shared', 'data', 'drugMasterlist.generated.ts');

export function composeSptNeatConcentration(spt) {
if (!spt) return '';
const { dilution, concentration } = spt;
if (dilution && concentration) {
return `${dilution} (${concentration})`;
}
if (dilution) return dilution;
if (concentration) return concentration;
return '';
}

export function transformSnapshotToProtocols(snapshot) {
const protocols = [];

for (const drug of snapshot.drugs || []) {
const drugName = drug.title;
const category = drug.dream?.category || '';
const sourceSlug = drug.slug;
const lastReviewed = drug.last_reviewed;

for (const protocol of drug.protocols || []) {
const hasSkin = Boolean(
protocol.spt ||
(protocol.idt && protocol.idt.length > 0) ||
(protocol.test_type && protocol.test_type !== 'challenge')
);
const hasChallenge = Boolean(
protocol.challenge &&
protocol.challenge.steps &&
protocol.challenge.steps.length > 0
);

const underReview = Boolean(protocol.under_review);
const needsPharmacyVerification = protocol.needs_pharmacy_verification === true;

// Case 1: Both skin and challenge -> Split into 2 records
if (hasSkin && hasChallenge) {
// Skin record
protocols.push({
id: protocol.id,
drugName,
category,
testType: protocol.test_type || 'skin',
presentation: protocol.presentation || '',
sptNeatConcentration: composeSptNeatConcentration(protocol.spt),
diluent: protocol.diluent || '',
idtSteps: (protocol.idt || []).map((step) => ({
ratio: step.dilution || '',
concentration: step.concentration || '',
})),
challengeSteps: [],
protocolLabel: protocol.label,
sourceSlug,
underReview,
lastReviewed,
...(needsPharmacyVerification ? { needsPharmacyVerification: true } : {}),
});

// Challenge record
protocols.push({
id: `${protocol.id}-challenge`,
drugName,
category,
testType: 'challenge',
presentation: protocol.presentation || '',
sptNeatConcentration: '',
diluent: '',
idtSteps: [],
challengeSteps: (protocol.challenge.steps || []).map((step, idx) => ({
step: idx + 1,
dose: step.dose || '',
volume: step.volume || '',
cumulative: step.cumulative || '',
})),
protocolLabel: `${protocol.label} Challenge`,
sourceSlug,
underReview,
lastReviewed,
...(needsPharmacyVerification ? { needsPharmacyVerification: true } : {}),
});
} else if (hasChallenge) {
// Only challenge record
protocols.push({
id: protocol.id,
drugName,
category,
testType: 'challenge',
presentation: protocol.presentation || '',
sptNeatConcentration: '',
diluent: '',
idtSteps: [],
challengeSteps: (protocol.challenge?.steps || []).map((step, idx) => ({
step: idx + 1,
dose: step.dose || '',
volume: step.volume || '',
cumulative: step.cumulative || '',
})),
protocolLabel: protocol.label,
sourceSlug,
underReview,
lastReviewed,
...(needsPharmacyVerification ? { needsPharmacyVerification: true } : {}),
});
} else {
// Skin only record
protocols.push({
id: protocol.id,
drugName,
category,
testType: protocol.test_type || 'skin',
presentation: protocol.presentation || '',
sptNeatConcentration: composeSptNeatConcentration(protocol.spt),
diluent: protocol.diluent || '',
idtSteps: (protocol.idt || []).map((step) => ({
ratio: step.dilution || '',
concentration: step.concentration || '',
})),
challengeSteps: [],
protocolLabel: protocol.label,
sourceSlug,
underReview,
lastReviewed,
...(needsPharmacyVerification ? { needsPharmacyVerification: true } : {}),
});
}
}
}

return protocols;
}

function formatStringLiteral(str) {
return JSON.stringify(str);
}

export function generateTypeScript(protocols) {
const lines = [
'// AUTO-GENERATED from src/shared/data/protocols.snapshot.json.',
'// Do NOT edit this file directly. Run `npm run protocols:generate` to regenerate.',
'',
"import type { DrugProtocol, IDTStep, ChallengeStep } from '@features/testing/types';",
'',
'// Compact helpers for readability',
'const s = (ratio: string, concentration: string): IDTStep => ({ ratio, concentration });',
'const c = (step: number, dose: string, volume: string, cumulative: string): ChallengeStep => ({ step, dose, volume, cumulative });',
'',
'export const GENERATED_PROTOCOLS: DrugProtocol[] = [',
];

for (const p of protocols) {
lines.push(' {');
lines.push(` id: ${formatStringLiteral(p.id)},`);
lines.push(` drugName: ${formatStringLiteral(p.drugName)},`);
if (p.needsPharmacyVerification) {
lines.push(' needsPharmacyVerification: true,');
}
lines.push(` category: ${formatStringLiteral(p.category)},`);
lines.push(` testType: ${formatStringLiteral(p.testType)},`);
lines.push(` presentation: ${formatStringLiteral(p.presentation)},`);
lines.push(` sptNeatConcentration: ${formatStringLiteral(p.sptNeatConcentration)},`);
lines.push(` diluent: ${formatStringLiteral(p.diluent)},`);

if (p.idtSteps.length === 0) {
lines.push(' idtSteps: [],');
} else {
const idtFormatted = p.idtSteps
.map((step) => `s(${formatStringLiteral(step.ratio)}, ${formatStringLiteral(step.concentration)})`)
.join(', ');
lines.push(` idtSteps: [${idtFormatted}],`);
}

if (p.challengeSteps.length === 0) {
lines.push(' challengeSteps: [],');
} else {
const challengeFormatted = p.challengeSteps
.map((step) => `c(${step.step}, ${formatStringLiteral(step.dose)}, ${formatStringLiteral(step.volume)}, ${formatStringLiteral(step.cumulative)})`)
.join(', ');
lines.push(` challengeSteps: [${challengeFormatted}],`);
}

lines.push(` protocolLabel: ${formatStringLiteral(p.protocolLabel)},`);
if (p.sourceSlug) {
lines.push(` sourceSlug: ${formatStringLiteral(p.sourceSlug)},`);
}
if (p.underReview !== undefined) {
lines.push(` underReview: ${p.underReview},`);
}
if (p.lastReviewed) {
lines.push(` lastReviewed: ${formatStringLiteral(p.lastReviewed)},`);
}
lines.push(' },');
}

lines.push('];');
lines.push('');
return lines.join('\n');
}

export function generateDrugMasterlist(snapshotPath = SNAPSHOT_PATH, outputPath = OUTPUT_PATH) {
const snapshotContent = readFileSync(snapshotPath, 'utf8');
const snapshot = JSON.parse(snapshotContent);
const protocols = transformSnapshotToProtocols(snapshot);
const code = generateTypeScript(protocols);
writeFileSync(outputPath, code, 'utf8');
return { protocolCount: protocols.length };
}

export function main() {
const { protocolCount } = generateDrugMasterlist();
console.log(`Generated ${protocolCount} protocol records in src/shared/data/drugMasterlist.generated.ts`);
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}
Loading
Loading