updated medicine schedules

This commit is contained in:
blaisadmin
2026-04-19 19:42:01 -04:00
parent 872b6c8663
commit d1657ef7ed
7 changed files with 363 additions and 66 deletions
+195 -44
View File
@@ -52,18 +52,28 @@ type Medication = {
birdId: string;
name: string;
dosage: string;
frequency: string;
frequency: MedicationFrequency;
doseSchedule: MedicationDoseScheduleItem[];
route: string | null;
startDate: string;
endDate: string | null;
notes: string | null;
};
type MedicationFrequency = 'once_daily' | 'twice_daily' | 'every_8_hours' | 'every_6_hours' | 'as_needed';
type MedicationDoseScheduleItem = {
key: string;
label: string;
time: string;
};
type MedicationAdministration = {
id: string;
medicationId: string;
birdId: string;
administeredOn: string;
administrationSlot: string;
status: 'administered' | 'missed';
notes: string | null;
createdAt: string;
@@ -471,6 +481,37 @@ const PHOTO_PREVIEW_SIZE = 112;
const MEMBER_CHART_WIDTH = 520;
const MEMBER_CHART_HEIGHT = 180;
const MEMBER_CHART_PADDING = { top: 16, right: 18, bottom: 34, left: 52 };
const medicationFrequencyOptions: { value: MedicationFrequency; label: string; doseSchedule: MedicationDoseScheduleItem[] }[] = [
{ value: 'once_daily', label: 'Once daily', doseSchedule: [{ key: 'dose-1', label: 'Morning', time: '08:00' }] },
{
value: 'twice_daily',
label: 'Twice daily',
doseSchedule: [
{ key: 'dose-1', label: 'Morning', time: '08:00' },
{ key: 'dose-2', label: 'Evening', time: '20:00' },
],
},
{
value: 'every_8_hours',
label: 'Every 8 hours',
doseSchedule: [
{ key: 'dose-1', label: 'Morning', time: '06:00' },
{ key: 'dose-2', label: 'Afternoon', time: '14:00' },
{ key: 'dose-3', label: 'Night', time: '22:00' },
],
},
{
value: 'every_6_hours',
label: 'Every 6 hours',
doseSchedule: [
{ key: 'dose-1', label: 'Early morning', time: '06:00' },
{ key: 'dose-2', label: 'Midday', time: '12:00' },
{ key: 'dose-3', label: 'Evening', time: '18:00' },
{ key: 'dose-4', label: 'Night', time: '00:00' },
],
},
{ value: 'as_needed', label: 'As needed', doseSchedule: [{ key: 'dose-1', label: 'As needed', time: '' }] },
];
const readJsonSafely = async <T,>(response: Response): Promise<T | null> => {
const contentType = response.headers.get('content-type') ?? '';
@@ -920,6 +961,46 @@ const buildMemberSeries = (
});
};
const getDefaultMedicationDoseSchedule = (frequency: MedicationFrequency) =>
(medicationFrequencyOptions.find((option) => option.value === frequency)?.doseSchedule ?? medicationFrequencyOptions[0].doseSchedule).map((slot) => ({
...slot,
}));
const formatMedicationFrequency = (frequency: MedicationFrequency | string) =>
medicationFrequencyOptions.find((option) => option.value === frequency)?.label ?? frequency;
const normalizeMedicationFrequency = (frequency: MedicationFrequency | string): MedicationFrequency => {
if (medicationFrequencyOptions.some((option) => option.value === frequency)) {
return frequency as MedicationFrequency;
}
const normalizedFrequency = frequency.toLowerCase();
if (normalizedFrequency.includes('12') || normalizedFrequency.includes('twice') || normalizedFrequency.includes('bid')) {
return 'twice_daily';
}
if (normalizedFrequency.includes('8') || normalizedFrequency.includes('three') || normalizedFrequency.includes('tid')) {
return 'every_8_hours';
}
if (normalizedFrequency.includes('6') || normalizedFrequency.includes('four') || normalizedFrequency.includes('qid')) {
return 'every_6_hours';
}
if (normalizedFrequency.includes('needed') || normalizedFrequency.includes('prn')) {
return 'as_needed';
}
return 'once_daily';
};
const formatDoseTime = (time: string) => {
if (!time) {
return '';
}
const [hourValue, minuteValue] = time.split(':').map(Number);
const date = new Date();
date.setHours(hourValue, minuteValue, 0, 0);
return new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit' }).format(date);
};
const assessBirdWeight = (bird: Bird): BirdWeightAssessment => {
const reference = findParrotWeightReference(bird.species);
@@ -1038,7 +1119,8 @@ function App() {
const [medicationForm, setMedicationForm] = useState({
name: '',
dosage: '',
frequency: '',
frequency: 'once_daily' as MedicationFrequency,
doseSchedule: getDefaultMedicationDoseSchedule('once_daily'),
route: '',
startDate: new Date().toISOString().slice(0, 10),
endDate: '',
@@ -2581,7 +2663,8 @@ function App() {
setMedicationForm({
name: '',
dosage: '',
frequency: '',
frequency: 'once_daily',
doseSchedule: getDefaultMedicationDoseSchedule('once_daily'),
route: '',
startDate: new Date().toISOString().slice(0, 10),
endDate: '',
@@ -2594,11 +2677,13 @@ function App() {
};
const handleEditMedication = (medication: Medication) => {
const frequency = normalizeMedicationFrequency(medication.frequency);
setEditingMedicationId(medication.id);
setMedicationForm({
name: medication.name,
dosage: medication.dosage,
frequency: medication.frequency,
frequency,
doseSchedule: medication.doseSchedule?.length ? medication.doseSchedule : getDefaultMedicationDoseSchedule(frequency),
route: medication.route ?? '',
startDate: medication.startDate,
endDate: medication.endDate ?? '',
@@ -2612,7 +2697,8 @@ function App() {
setMedicationForm({
name: '',
dosage: '',
frequency: '',
frequency: 'once_daily',
doseSchedule: getDefaultMedicationDoseSchedule('once_daily'),
route: '',
startDate: new Date().toISOString().slice(0, 10),
endDate: '',
@@ -2649,12 +2735,16 @@ function App() {
}
};
const handleMedicationAdministrationSubmit = async (medicationId: string, status: MedicationAdministration['status']) => {
const handleMedicationAdministrationSubmit = async (
medicationId: string,
administrationSlot: string,
status: MedicationAdministration['status'],
) => {
if (!selectedBird || savingMedicationAdministrationId) {
return;
}
setSavingMedicationAdministrationId(`${medicationId}-${status}`);
setSavingMedicationAdministrationId(`${medicationId}-${administrationSlot}-${status}`);
setError('');
try {
@@ -2662,7 +2752,7 @@ function App() {
const response = await apiFetch(`/birds/${selectedBird.id}/medications/${medicationId}/administrations`, authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ administeredOn, status }),
body: JSON.stringify({ administeredOn, administrationSlot, status }),
});
if (!response.ok) {
@@ -2680,7 +2770,9 @@ function App() {
(administration, index, all) =>
all.findIndex(
(candidate) =>
candidate.medicationId === administration.medicationId && candidate.administeredOn === administration.administeredOn,
candidate.medicationId === administration.medicationId &&
candidate.administeredOn === administration.administeredOn &&
candidate.administrationSlot === administration.administrationSlot,
) === index,
)
.sort((left, right) => right.administeredOn.localeCompare(left.administeredOn) || right.createdAt.localeCompare(left.createdAt)),
@@ -3294,18 +3386,14 @@ function App() {
const showWorkspaceSwitcher = authSession.workspaces.length > 1;
const todayDate = new Date().toISOString().slice(0, 10);
const renderMedicationCard = (medication: Medication, options: { showActions?: boolean; showAdministrationControls?: boolean }) => {
const doseSlots = medication.doseSchedule?.length ? medication.doseSchedule : getDefaultMedicationDoseSchedule(medication.frequency);
const latestAdministration = medicationAdministrations.find((administration) => administration.medicationId === medication.id);
const todayAdministration = medicationAdministrations.find(
(administration) => administration.medicationId === medication.id && administration.administeredOn === todayDate,
);
const givenActionId = `${medication.id}-administered`;
const missedActionId = `${medication.id}-missed`;
return (
<article key={medication.id} className="vet-visit-card">
<strong>{medication.name}</strong>
<span>
{medication.dosage} {medication.frequency}
{medication.dosage} {formatMedicationFrequency(medication.frequency)}
{medication.route ? `${medication.route}` : ''}
</span>
<small>
@@ -3314,35 +3402,51 @@ function App() {
<small>{medication.notes || 'No notes recorded.'}</small>
{latestAdministration ? (
<small>
Last update: {latestAdministration.status === 'administered' ? 'Given' : 'Missed'} on {formatShortDate(latestAdministration.administeredOn)}
Last update: {latestAdministration.status === 'administered' ? 'Given' : 'Not administered'} on{' '}
{formatShortDate(latestAdministration.administeredOn)}
</small>
) : null}
{options.showAdministrationControls ? (
<div className="medication-admin-actions">
<small>
Today:{' '}
{todayAdministration
? `${todayAdministration.status === 'administered' ? 'Given' : 'Missed'} on ${formatShortDate(todayAdministration.administeredOn)}`
: 'Not updated yet'}
</small>
<div className="button-row">
<button
className="primary-button"
onClick={() => handleMedicationAdministrationSubmit(medication.id, 'administered')}
type="button"
disabled={Boolean(savingMedicationAdministrationId)}
>
{savingMedicationAdministrationId === givenActionId ? 'Saving...' : 'Given today'}
</button>
<button
className="secondary-button"
onClick={() => handleMedicationAdministrationSubmit(medication.id, 'missed')}
type="button"
disabled={Boolean(savingMedicationAdministrationId)}
>
{savingMedicationAdministrationId === missedActionId ? 'Saving...' : 'Missed today'}
</button>
</div>
<small>Today's interval events</small>
{doseSlots.map((slot) => {
const todayAdministration = medicationAdministrations.find(
(administration) =>
administration.medicationId === medication.id &&
administration.administeredOn === todayDate &&
administration.administrationSlot === slot.key,
);
const givenActionId = `${medication.id}-${slot.key}-administered`;
const missedActionId = `${medication.id}-${slot.key}-missed`;
return (
<div className="medication-dose-row" key={slot.key}>
<span>
<strong>{slot.label}</strong>
{slot.time ? <small>{formatDoseTime(slot.time)}</small> : null}
<small>{todayAdministration ? (todayAdministration.status === 'administered' ? 'Administered' : 'Not administered') : 'Unmarked'}</small>
</span>
<div className="button-row">
<button
className="primary-button"
onClick={() => handleMedicationAdministrationSubmit(medication.id, slot.key, 'administered')}
type="button"
disabled={Boolean(savingMedicationAdministrationId)}
>
{savingMedicationAdministrationId === givenActionId ? 'Saving...' : 'Administered'}
</button>
<button
className="secondary-button"
onClick={() => handleMedicationAdministrationSubmit(medication.id, slot.key, 'missed')}
type="button"
disabled={Boolean(savingMedicationAdministrationId)}
>
{savingMedicationAdministrationId === missedActionId ? 'Saving...' : 'Not administered'}
</button>
</div>
</div>
);
})}
</div>
) : null}
{options.showActions ? (
@@ -5068,12 +5172,24 @@ function App() {
</label>
<label>
Frequency
<input
<select
value={medicationForm.frequency}
onChange={(event) => setMedicationForm({ ...medicationForm, frequency: event.target.value })}
placeholder="Every 12 hours"
onChange={(event) => {
const frequency = event.target.value as MedicationFrequency;
setMedicationForm({
...medicationForm,
frequency,
doseSchedule: getDefaultMedicationDoseSchedule(frequency),
});
}}
required
/>
>
{medicationFrequencyOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
Route
@@ -5100,6 +5216,41 @@ function App() {
onChange={(event) => setMedicationForm({ ...medicationForm, endDate: event.target.value })}
/>
</label>
<label className="wide-field">
Dose labels and times
<div className="dose-schedule-editor">
{medicationForm.doseSchedule.map((slot, index) => (
<div className="dose-schedule-row" key={slot.key}>
<input
value={slot.label}
onChange={(event) =>
setMedicationForm({
...medicationForm,
doseSchedule: medicationForm.doseSchedule.map((currentSlot, currentIndex) =>
currentIndex === index ? { ...currentSlot, label: event.target.value } : currentSlot,
),
})
}
aria-label={`${slot.key} label`}
required
/>
<input
type="time"
value={slot.time}
onChange={(event) =>
setMedicationForm({
...medicationForm,
doseSchedule: medicationForm.doseSchedule.map((currentSlot, currentIndex) =>
currentIndex === index ? { ...currentSlot, time: event.target.value } : currentSlot,
),
})
}
aria-label={`${slot.key} time`}
/>
</div>
))}
</div>
</label>
<label className="wide-field">
Notes
<textarea
+33
View File
@@ -1196,6 +1196,39 @@ textarea {
padding-top: 0.35rem;
}
.medication-dose-row {
display: grid;
grid-template-columns: minmax(120px, 1fr) auto;
gap: 0.75rem;
align-items: center;
padding: 0.7rem;
border: 1px solid rgba(53, 129, 98, 0.18);
border-radius: 16px;
background: rgba(255, 255, 255, 0.46);
}
.medication-dose-row > span {
display: grid;
gap: 0.15rem;
}
.dose-schedule-editor {
display: grid;
gap: 0.65rem;
margin-top: 0.5rem;
}
.dose-schedule-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 150px;
gap: 0.75rem;
align-items: center;
}
.dose-schedule-row input {
margin-top: 0;
}
.form-panel {
display: grid;
gap: 1rem;