diff --git a/backend/src/app.ts b/backend/src/app.ts index 1b9a9d3..73fc03f 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -230,7 +230,17 @@ const medicationSchema = z .object({ name: z.string().trim().min(1).max(160), dosage: z.string().trim().min(1).max(160), - frequency: z.string().trim().min(1).max(160), + frequency: z.enum(['once_daily', 'twice_daily', 'every_8_hours', 'every_6_hours', 'as_needed']), + doseSchedule: z + .array( + z.object({ + key: z.string().trim().min(1).max(80), + label: z.string().trim().min(1).max(80), + time: z.string().trim().regex(/^$|^\d{2}:\d{2}$/), + }), + ) + .min(1) + .max(8), route: z.string().trim().max(80).optional().or(z.literal('')), startDate: dateStringSchema, endDate: dateStringSchema.optional().or(z.literal('')), @@ -243,6 +253,7 @@ const medicationSchema = z const medicationAdministrationSchema = z.object({ administeredOn: dateStringSchema, + administrationSlot: z.string().trim().min(1).max(80).default('dose-1'), status: z.enum(['administered', 'missed']), notes: z.string().trim().max(500).optional().or(z.literal('')), }); @@ -444,6 +455,7 @@ const normalizeMedication = (row: MedicationRow) => ({ name: row.name, dosage: row.dosage, frequency: row.frequency, + doseSchedule: row.dose_schedule, route: row.route, startDate: row.start_date, endDate: row.end_date, @@ -455,6 +467,7 @@ const normalizeMedicationAdministration = (row: MedicationAdministrationRow) => medicationId: row.medication_id, birdId: row.bird_id, administeredOn: row.administered_on, + administrationSlot: row.administration_slot, status: row.status, notes: row.notes, createdByUserId: row.created_by_user_id, @@ -2229,6 +2242,7 @@ app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requ parsed.data.name, parsed.data.dosage, parsed.data.frequency, + parsed.data.doseSchedule, emptyToNull(parsed.data.route), parsed.data.startDate, emptyToNull(parsed.data.endDate), @@ -2263,6 +2277,7 @@ app.put('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWrit parsed.data.name, parsed.data.dosage, parsed.data.frequency, + parsed.data.doseSchedule, emptyToNull(parsed.data.route), parsed.data.startDate, emptyToNull(parsed.data.endDate), @@ -2325,6 +2340,7 @@ app.post('/api/birds/:birdId/medications/:medicationId/administrations', require req.params.birdId, req.auth!.workspace.id, parsed.data.administeredOn, + parsed.data.administrationSlot, parsed.data.status, emptyToNull(parsed.data.notes), req.auth!.user.id, diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 833b531..c239c57 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -287,6 +287,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => { name VARCHAR(160) NOT NULL, dosage VARCHAR(160) NOT NULL, frequency VARCHAR(160) NOT NULL, + dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb, route VARCHAR(80), start_date DATE NOT NULL, end_date DATE, @@ -295,18 +296,30 @@ export const ensureSchema = async (database: DatabaseClient = db) => { CHECK (end_date IS NULL OR end_date >= start_date) ); + ALTER TABLE medications + ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb; + CREATE TABLE IF NOT EXISTS medication_administrations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), medication_id UUID NOT NULL REFERENCES medications(id) ON DELETE CASCADE, bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, administered_on DATE NOT NULL, + administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1', status VARCHAR(20) NOT NULL CHECK (status IN ('administered', 'missed')), notes VARCHAR(500), created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE (medication_id, administered_on) + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); + ALTER TABLE medication_administrations + ADD COLUMN IF NOT EXISTS administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1'; + + ALTER TABLE medication_administrations + DROP CONSTRAINT IF EXISTS medication_administrations_medication_id_administered_on_key; + + CREATE UNIQUE INDEX IF NOT EXISTS idx_medication_administrations_unique_slot + ON medication_administrations (medication_id, administered_on, administration_slot); + CREATE INDEX IF NOT EXISTS idx_weight_records_bird_recorded_on ON weight_records (bird_id, recorded_on DESC); diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts index d2b86e1..fe161b1 100644 --- a/backend/src/repositories/birdRepository.ts +++ b/backend/src/repositories/birdRepository.ts @@ -4,6 +4,7 @@ import type { BirdRow, LostBirdMatchRow, MedicationAdministrationRow, + MedicationDoseScheduleItem, MedicationRow, PendingBirdTransferRow, VetVisitRow, @@ -415,7 +416,7 @@ export const deleteVetVisitForBird = async (visitId: string, birdId: string) => export const listMedicationsForBird = async (birdId: string, workspaceId: number) => { const result = await db.query( - `SELECT id, bird_id, name, dosage, frequency, route, start_date::text, end_date::text, notes + `SELECT id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes FROM medications WHERE bird_id = $1 AND EXISTS ( @@ -436,16 +437,17 @@ export const createMedicationForBird = async ( name: string, dosage: string, frequency: string, + doseSchedule: MedicationDoseScheduleItem[], route: string | null, startDate: string, endDate: string | null, notes: string | null, ) => { const result = await db.query( - `INSERT INTO medications (bird_id, name, dosage, frequency, route, start_date, end_date, notes) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id, bird_id, name, dosage, frequency, route, start_date::text, end_date::text, notes`, - [birdId, name, dosage, frequency, route, startDate, endDate, notes], + `INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`, + [birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes], ); return result.rows[0] ?? null; @@ -457,6 +459,7 @@ export const updateMedicationForBird = async ( name: string, dosage: string, frequency: string, + doseSchedule: MedicationDoseScheduleItem[], route: string | null, startDate: string, endDate: string | null, @@ -467,14 +470,15 @@ export const updateMedicationForBird = async ( SET name = $3, dosage = $4, frequency = $5, - route = $6, - start_date = $7, - end_date = $8, - notes = $9 + dose_schedule = $6, + route = $7, + start_date = $8, + end_date = $9, + notes = $10 WHERE id = $1 AND bird_id = $2 - RETURNING id, bird_id, name, dosage, frequency, route, start_date::text, end_date::text, notes`, - [medicationId, birdId, name, dosage, frequency, route, startDate, endDate, notes], + RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`, + [medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes], ); return result.rows[0] ?? null; @@ -494,7 +498,7 @@ export const deleteMedicationForBird = async (medicationId: string, birdId: stri export const listMedicationAdministrationsForBird = async (birdId: string, workspaceId: number) => { const result = await db.query( - `SELECT id, medication_id, bird_id, administered_on::text, status, notes, created_by_user_id, created_at + `SELECT id, medication_id, bird_id, administered_on::text, administration_slot, status, notes, created_by_user_id, created_at FROM medication_administrations WHERE bird_id = $1 AND EXISTS ( @@ -515,13 +519,14 @@ export const upsertMedicationAdministrationForBird = async ( birdId: string, workspaceId: number, administeredOn: string, + administrationSlot: string, status: 'administered' | 'missed', notes: string | null, createdByUserId: string | null, ) => { const result = await db.query( - `INSERT INTO medication_administrations (medication_id, bird_id, administered_on, status, notes, created_by_user_id) - SELECT $1, $2, $4, $5, $6, $7 + `INSERT INTO medication_administrations (medication_id, bird_id, administered_on, administration_slot, status, notes, created_by_user_id) + SELECT $1, $2, $4, $5, $6, $7, $8 WHERE EXISTS ( SELECT 1 FROM medications @@ -530,13 +535,13 @@ export const upsertMedicationAdministrationForBird = async ( AND medications.bird_id = $2 AND birds.workspace_id = $3 ) - ON CONFLICT (medication_id, administered_on) + ON CONFLICT (medication_id, administered_on, administration_slot) DO UPDATE SET status = EXCLUDED.status, notes = EXCLUDED.notes, created_by_user_id = EXCLUDED.created_by_user_id, created_at = CURRENT_TIMESTAMP - RETURNING id, medication_id, bird_id, administered_on::text, status, notes, created_by_user_id, created_at`, - [medicationId, birdId, workspaceId, administeredOn, status, notes, createdByUserId], + RETURNING id, medication_id, bird_id, administered_on::text, administration_slot, status, notes, created_by_user_id, created_at`, + [medicationId, birdId, workspaceId, administeredOn, administrationSlot, status, notes, createdByUserId], ); return result.rows[0] ?? null; diff --git a/backend/src/types.ts b/backend/src/types.ts index b4996f5..ee6f06c 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -150,17 +150,25 @@ export type MedicationRow = { name: string; dosage: string; frequency: string; + dose_schedule: MedicationDoseScheduleItem[]; route: string | null; start_date: string; end_date: string | null; notes: string | null; }; +export type MedicationDoseScheduleItem = { + key: string; + label: string; + time: string; +}; + export type MedicationAdministrationRow = { id: string; medication_id: string; bird_id: string; administered_on: string; + administration_slot: string; status: 'administered' | 'missed'; notes: string | null; created_by_user_id: string | null; diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index b34db35..cb0a171 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -254,7 +254,19 @@ Role requirements are called out per endpoint below. If the signed-in member lac "birdId": "uuid", "name": "Meloxicam", "dosage": "0.05 mL", - "frequency": "Every 12 hours", + "frequency": "twice_daily", + "doseSchedule": [ + { + "key": "dose-1", + "label": "Morning", + "time": "08:00" + }, + { + "key": "dose-2", + "label": "Evening", + "time": "20:00" + } + ], "route": "Oral", "startDate": "2026-04-14", "endDate": null, @@ -262,6 +274,22 @@ Role requirements are called out per endpoint below. If the signed-in member lac } ``` +### Medication Administration + +```json +{ + "id": "uuid", + "medicationId": "uuid", + "birdId": "uuid", + "administeredOn": "2026-04-14", + "administrationSlot": "dose-1", + "status": "administered", + "notes": null, + "createdByUserId": "uuid", + "createdAt": "2026-04-14T12:34:56.000Z" +} +``` + ## Common Validation Rules - Dates use `YYYY-MM-DD` @@ -926,7 +954,19 @@ Request body: { "name": "Meloxicam", "dosage": "0.05 mL", - "frequency": "Every 12 hours", + "frequency": "twice_daily", + "doseSchedule": [ + { + "key": "dose-1", + "label": "Morning", + "time": "08:00" + }, + { + "key": "dose-2", + "label": "Evening", + "time": "20:00" + } + ], "route": "Oral", "startDate": "2026-04-14", "endDate": "", @@ -950,6 +990,37 @@ Requires auth with write access and role `owner`, `assistant`, or `caregiver`. U Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a medication record. +#### `GET /api/birds/:birdId/medication-administrations` + +Requires auth. Lists medication administration events for a bird in the active workspace. + +Response `200`: + +```json +{ + "administrations": [] +} +``` + +#### `POST /api/birds/:birdId/medications/:medicationId/administrations` + +Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Upserts one scheduled medication event for a date and interval slot. + +Request body: + +```json +{ + "administeredOn": "2026-04-14", + "administrationSlot": "dose-1", + "status": "administered", + "notes": "" +} +``` + +`status` is `administered` or `missed`. `administrationSlot` identifies the interval event for that day, such as `dose-1` or `dose-2`. + +Medication `frequency` is one of `once_daily`, `twice_daily`, `every_8_hours`, `every_6_hours`, or `as_needed`. `doseSchedule` stores the editable labels and optional 24-hour `HH:MM` times used by administration slots. + Possible errors: - `400` if `endDate` is before `startDate` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 767e2dd..d861b4b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 (response: Response): Promise => { 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 (
{medication.name} - {medication.dosage} • {medication.frequency} + {medication.dosage} • {formatMedicationFrequency(medication.frequency)} {medication.route ? ` • ${medication.route}` : ''} @@ -3314,35 +3402,51 @@ function App() { {medication.notes || 'No notes recorded.'} {latestAdministration ? ( - Last update: {latestAdministration.status === 'administered' ? 'Given' : 'Missed'} on {formatShortDate(latestAdministration.administeredOn)} + Last update: {latestAdministration.status === 'administered' ? 'Given' : 'Not administered'} on{' '} + {formatShortDate(latestAdministration.administeredOn)} ) : null} {options.showAdministrationControls ? (
- - Today:{' '} - {todayAdministration - ? `${todayAdministration.status === 'administered' ? 'Given' : 'Missed'} on ${formatShortDate(todayAdministration.administeredOn)}` - : 'Not updated yet'} - -
- - -
+ Today's interval events + {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 ( +
+ + {slot.label} + {slot.time ? {formatDoseTime(slot.time)} : null} + {todayAdministration ? (todayAdministration.status === 'administered' ? 'Administered' : 'Not administered') : 'Unmarked'} + +
+ + +
+
+ ); + })}
) : null} {options.showActions ? ( @@ -5068,12 +5172,24 @@ function App() { +