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
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.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.
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, dilution concentrations, and cross-reactivity guidance are tied to the SCRATCH drug library (repository `monchee/drug-library`, hosted at [scratch.yuson.au](https://scratch.yuson.au)), which serves as the source of truth that DREAM consumes as a pinned JSON snapshot schema 1.1 (`src/shared/data/protocols.snapshot.json`). All protocol and cross-reactivity changes originate in SCRATCH.

---

Expand Down
14 changes: 9 additions & 5 deletions scripts/sync-protocols.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ const DEFAULT_LOCAL_PATH = '/Users/monchee/Projects/scratch/docs/api/protocols.j
// project only gets its requested *.pages.dev name if it is free, and it was not.
export const PUBLISHED_PROTOCOLS_URL = 'https://scratch.yuson.au/api/protocols.json';

const SUPPORTED_SCHEMA_VERSIONS = ['1.0'];
export const SUPPORTED_SCHEMA_VERSIONS = ['1.0', '1.1'];

export function isSupportedSchemaVersion(version) {
return typeof version === 'string' && SUPPORTED_SCHEMA_VERSIONS.includes(version);
}

export function computeDoseLevelDiff(oldSnapshot, newSnapshot) {
const diffs = [];
Expand Down Expand Up @@ -238,10 +242,10 @@ export async function syncProtocols(options = {}) {
newSnapshotRaw = JSON.parse(fileContent);
}

if (!SUPPORTED_SCHEMA_VERSIONS.includes(newSnapshotRaw.schema_version)) {
console.error(`\nError: Unrecognised schema_version "${newSnapshotRaw.schema_version}".`);
console.error(`Supported versions: ${SUPPORTED_SCHEMA_VERSIONS.join(', ')}`);
process.exit(1);
if (!isSupportedSchemaVersion(newSnapshotRaw.schema_version)) {
const errorMsg = `Unrecognised schema_version "${newSnapshotRaw.schema_version}". Supported versions: ${SUPPORTED_SCHEMA_VERSIONS.join(', ')}`;
console.error(`\nError: ${errorMsg}`);
throw new Error(errorMsg);
}

let oldSnapshot = null;
Expand Down
63 changes: 63 additions & 0 deletions src/features/info-pages/components/DrugReferencePage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import DrugReferencePage from './DrugReferencePage';
import { CROSS_REACTIVITY_ITEMS, CROSS_REACTIVITY_GOVERNANCE } from '@shared/data/crossReactivity';
import snapshot from '@shared/data/protocols.snapshot.json';

describe('DrugReferencePage Component', () => {
const setScreenMock = vi.fn();

it('renders all six cross-reactivity categories from the pinned snapshot/adapter', () => {
const { container } = render(<DrugReferencePage setScreen={setScreenMock} />);

expect(CROSS_REACTIVITY_ITEMS).toHaveLength(6);
expect(CROSS_REACTIVITY_ITEMS).toEqual(snapshot.cross_reactivity.items);

// Verify each category exists as an accordion trigger
const triggers = container.querySelectorAll('[data-radix-collection-item]');
expect(triggers.length).toBe(6);

for (let i = 0; i < CROSS_REACTIVITY_ITEMS.length; i++) {
const item = CROSS_REACTIVITY_ITEMS[i];
const trigger = triggers[i];
expect(trigger).toHaveTextContent(item.category);

// Open accordion item to verify content and alternatives
fireEvent.click(trigger);
expect(screen.getByText(item.info)).toBeInTheDocument();
expect(screen.getByText(item.alternatives, { exact: false })).toBeInTheDocument();
}
});

it('renders the "Alternatives:" label for each cross-reactivity item when expanded', () => {
const { container } = render(<DrugReferencePage setScreen={setScreenMock} />);

const triggers = container.querySelectorAll('[data-radix-collection-item]');
for (const trigger of triggers) {
fireEvent.click(trigger);
}

const altLabels = screen.getAllByText('Alternatives:');
expect(altLabels).toHaveLength(6);
});

it('renders the "Clinical review pending" notice when under_review is true', () => {
render(<DrugReferencePage setScreen={setScreenMock} />);

expect(CROSS_REACTIVITY_GOVERNANCE.under_review).toBe(true);
const notice = screen.getByText(/Clinical review pending/i);
expect(notice).toBeInTheDocument();
});

it('does not invent or render reviewer or date when fields are blank', () => {
render(<DrugReferencePage setScreen={setScreenMock} />);

expect(CROSS_REACTIVITY_GOVERNANCE.reviewed_by).toBe('');
expect(CROSS_REACTIVITY_GOVERNANCE.last_reviewed).toBe('');

// Ensure no fallback date/reviewer strings are rendered
expect(screen.queryByText(/Reviewed by/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Review date/i)).not.toBeInTheDocument();
});
});
54 changes: 17 additions & 37 deletions src/features/info-pages/components/DrugReferencePage.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,15 @@
import React from 'react';
import { Card, CardContent, Button, Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui';
import { Card, CardContent, Button, Accordion, AccordionItem, AccordionTrigger, AccordionContent, Badge } from '@/components/ui';
import { Home, AlertTriangle, ArrowRight } from 'lucide-react';
import { Screen } from '@shared/types';
import { DRUG_CATEGORIES, CATEGORY_THEMES } from '@shared/utils/constants';
import { CROSS_REACTIVITY_ITEMS, CROSS_REACTIVITY_GOVERNANCE } from '@shared/data/crossReactivity';

interface DrugReferencePageProps {
setScreen: (screen: Screen) => void;
}

const DrugReferencePage: React.FC<DrugReferencePageProps> = ({ setScreen }) => {
const crossReactivityInfo = [
{
category: "Muscle Relaxants",
info: "Quaternary ammonium compounds are thought to be responsible for most reactions. Cross-reactivity between agents is common due to structural similarities.",
alternatives: "Consider non-depolarising agents with different structures. Cisatracurium may have lower immunogenicity."
},
{
category: "Penicillins",
info: "Cross-reactivity with cephalosporins depends on side chain similarity. First-generation cephalosporins have higher cross-reactivity (~2%).",
alternatives: "Cephalosporins with dissimilar side chains, carbapenems (low cross-reactivity), or non-beta-lactams."
},
{
category: "Cephalosporins",
info: "Cross-reactivity is more related to R1 side chain similarity than the beta-lactam ring. Later generations have different side chains.",
alternatives: "Cephalosporins with different side chains, carbapenems, aztreonam (minimal cross-reactivity)."
},
{
category: "Local Anaesthetics",
info: "True allergy is rare (<1%). Most reactions are vasovagal or due to adrenaline. Amide types rarely cross-react with each other.",
alternatives: "Different amide local anaesthetic, or ester type if amide allergy confirmed."
},
{
category: "Opioids",
info: "Can cause direct mast cell degranulation (non-IgE mediated). Morphine and codeine are most histamine-releasing.",
alternatives: "Fentanyl or remifentanil (less histamine release), or non-opioid analgesia."
},
{
category: "Hypnotics",
info: "Propofol reactions may be to the lipid emulsion or specific to propofol. Egg/soya allergy is not a contraindication.",
alternatives: "Different induction agent (thiopentone, ketamine, etomidate)."
}
];

return (
<div className="py-4 sm:p-6 space-y-6">
Expand Down Expand Up @@ -95,11 +64,22 @@ const DrugReferencePage: React.FC<DrugReferencePageProps> = ({ setScreen }) => {

{/* Cross-Reactivity & Alternatives */}
<div>
<h3 className="section-label mb-3">
Cross-Reactivity & Alternatives
</h3>
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
<h3 className="section-label mb-0">
Cross-Reactivity & Alternatives
</h3>
{CROSS_REACTIVITY_GOVERNANCE.under_review && (
<Badge
variant="warning"
className="rounded-none border border-status-warning/40 px-2 py-0.5 text-xs font-semibold"
>
<AlertTriangle className="w-3.5 h-3.5 mr-1.5 shrink-0" aria-hidden="true" />
Clinical review pending
</Badge>
)}
</div>
<Accordion type="multiple" className="bg-card rounded-none border border-border px-4">
{crossReactivityInfo.map((item, idx) => (
{CROSS_REACTIVITY_ITEMS.map((item, idx) => (
<AccordionItem
key={idx}
value={`item-${idx}`}
Expand Down
104 changes: 104 additions & 0 deletions src/shared/data/__tests__/syncProtocols.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest';
import {
SUPPORTED_SCHEMA_VERSIONS,
isSupportedSchemaVersion,
computeDoseLevelDiff,
} from '../../../../scripts/sync-protocols.mjs';

describe('sync-protocols schema version validation', () => {
it('supports schema versions 1.0 and 1.1', () => {
expect(SUPPORTED_SCHEMA_VERSIONS).toContain('1.0');
expect(SUPPORTED_SCHEMA_VERSIONS).toContain('1.1');
expect(SUPPORTED_SCHEMA_VERSIONS).toEqual(['1.0', '1.1']);
});

it('accepts schema version 1.0 and 1.1 via isSupportedSchemaVersion', () => {
expect(isSupportedSchemaVersion('1.0')).toBe(true);
expect(isSupportedSchemaVersion('1.1')).toBe(true);
});

it('rejects unrecognised or malformed schema versions', () => {
expect(isSupportedSchemaVersion('0.9')).toBe(false);
expect(isSupportedSchemaVersion('1.2')).toBe(false);
expect(isSupportedSchemaVersion('2.0')).toBe(false);
expect(isSupportedSchemaVersion('')).toBe(false);
expect(isSupportedSchemaVersion(undefined as unknown as string)).toBe(false);
});
});

describe('sync-protocols dose level diff review gate', () => {
it('detects no diff when drug protocols are identical', () => {
const snapshot = {
schema_version: '1.1',
drugs: [
{
slug: 'test-drug',
title: 'Test Drug',
version: '1.0',
last_reviewed: 'review-marker-before',
protocols: [
{
id: 'iv',
label: 'IV',
test_type: 'skin',
presentation: 'presentation-before',
diluent: 'diluent-placeholder',
under_review: false,
needs_pharmacy_verification: false,
spt: { dilution: 'dilution-before', concentration: 'concentration-before' },
idt: [{ dilution: 'dilution-before', concentration: 'concentration-before' }],
challenge: { interval: 'interval-placeholder', steps: [{ dose: 'dose-placeholder' }] },
},
],
},
],
};

const diffs = computeDoseLevelDiff(snapshot, snapshot);
expect(diffs).toEqual([]);
});

it('detects modifications in drug protocols', () => {
const oldSnapshot = {
drugs: [
{
slug: 'test-drug',
title: 'Test Drug',
version: '1.0',
last_reviewed: 'review-marker-before',
protocols: [
{
id: 'iv',
label: 'IV',
test_type: 'skin',
spt: { dilution: 'dilution-before', concentration: 'concentration-before' },
},
],
},
],
};

const newSnapshot = {
drugs: [
{
slug: 'test-drug',
title: 'Test Drug',
version: '1.1',
last_reviewed: 'review-marker-after',
protocols: [
{
id: 'iv',
label: 'IV',
test_type: 'skin',
spt: { dilution: 'dilution-after', concentration: 'concentration-after' },
},
],
},
],
};

const diffs = computeDoseLevelDiff(oldSnapshot, newSnapshot);
expect(diffs).toHaveLength(1);
expect(diffs[0].type).toBe('DRUG_MODIFIED');
});
});
Loading