updated medicine schedules
This commit is contained in:
+17
-1
@@ -230,7 +230,17 @@ const medicationSchema = z
|
|||||||
.object({
|
.object({
|
||||||
name: z.string().trim().min(1).max(160),
|
name: z.string().trim().min(1).max(160),
|
||||||
dosage: 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('')),
|
route: z.string().trim().max(80).optional().or(z.literal('')),
|
||||||
startDate: dateStringSchema,
|
startDate: dateStringSchema,
|
||||||
endDate: dateStringSchema.optional().or(z.literal('')),
|
endDate: dateStringSchema.optional().or(z.literal('')),
|
||||||
@@ -243,6 +253,7 @@ const medicationSchema = z
|
|||||||
|
|
||||||
const medicationAdministrationSchema = z.object({
|
const medicationAdministrationSchema = z.object({
|
||||||
administeredOn: dateStringSchema,
|
administeredOn: dateStringSchema,
|
||||||
|
administrationSlot: z.string().trim().min(1).max(80).default('dose-1'),
|
||||||
status: z.enum(['administered', 'missed']),
|
status: z.enum(['administered', 'missed']),
|
||||||
notes: z.string().trim().max(500).optional().or(z.literal('')),
|
notes: z.string().trim().max(500).optional().or(z.literal('')),
|
||||||
});
|
});
|
||||||
@@ -444,6 +455,7 @@ const normalizeMedication = (row: MedicationRow) => ({
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
dosage: row.dosage,
|
dosage: row.dosage,
|
||||||
frequency: row.frequency,
|
frequency: row.frequency,
|
||||||
|
doseSchedule: row.dose_schedule,
|
||||||
route: row.route,
|
route: row.route,
|
||||||
startDate: row.start_date,
|
startDate: row.start_date,
|
||||||
endDate: row.end_date,
|
endDate: row.end_date,
|
||||||
@@ -455,6 +467,7 @@ const normalizeMedicationAdministration = (row: MedicationAdministrationRow) =>
|
|||||||
medicationId: row.medication_id,
|
medicationId: row.medication_id,
|
||||||
birdId: row.bird_id,
|
birdId: row.bird_id,
|
||||||
administeredOn: row.administered_on,
|
administeredOn: row.administered_on,
|
||||||
|
administrationSlot: row.administration_slot,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
createdByUserId: row.created_by_user_id,
|
createdByUserId: row.created_by_user_id,
|
||||||
@@ -2229,6 +2242,7 @@ app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requ
|
|||||||
parsed.data.name,
|
parsed.data.name,
|
||||||
parsed.data.dosage,
|
parsed.data.dosage,
|
||||||
parsed.data.frequency,
|
parsed.data.frequency,
|
||||||
|
parsed.data.doseSchedule,
|
||||||
emptyToNull(parsed.data.route),
|
emptyToNull(parsed.data.route),
|
||||||
parsed.data.startDate,
|
parsed.data.startDate,
|
||||||
emptyToNull(parsed.data.endDate),
|
emptyToNull(parsed.data.endDate),
|
||||||
@@ -2263,6 +2277,7 @@ app.put('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWrit
|
|||||||
parsed.data.name,
|
parsed.data.name,
|
||||||
parsed.data.dosage,
|
parsed.data.dosage,
|
||||||
parsed.data.frequency,
|
parsed.data.frequency,
|
||||||
|
parsed.data.doseSchedule,
|
||||||
emptyToNull(parsed.data.route),
|
emptyToNull(parsed.data.route),
|
||||||
parsed.data.startDate,
|
parsed.data.startDate,
|
||||||
emptyToNull(parsed.data.endDate),
|
emptyToNull(parsed.data.endDate),
|
||||||
@@ -2325,6 +2340,7 @@ app.post('/api/birds/:birdId/medications/:medicationId/administrations', require
|
|||||||
req.params.birdId,
|
req.params.birdId,
|
||||||
req.auth!.workspace.id,
|
req.auth!.workspace.id,
|
||||||
parsed.data.administeredOn,
|
parsed.data.administeredOn,
|
||||||
|
parsed.data.administrationSlot,
|
||||||
parsed.data.status,
|
parsed.data.status,
|
||||||
emptyToNull(parsed.data.notes),
|
emptyToNull(parsed.data.notes),
|
||||||
req.auth!.user.id,
|
req.auth!.user.id,
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
name VARCHAR(160) NOT NULL,
|
name VARCHAR(160) NOT NULL,
|
||||||
dosage VARCHAR(160) NOT NULL,
|
dosage VARCHAR(160) NOT NULL,
|
||||||
frequency 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),
|
route VARCHAR(80),
|
||||||
start_date DATE NOT NULL,
|
start_date DATE NOT NULL,
|
||||||
end_date DATE,
|
end_date DATE,
|
||||||
@@ -295,18 +296,30 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
CHECK (end_date IS NULL OR end_date >= start_date)
|
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 (
|
CREATE TABLE IF NOT EXISTS medication_administrations (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
medication_id UUID NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
medication_id UUID NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
||||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
administered_on DATE NOT NULL,
|
administered_on DATE NOT NULL,
|
||||||
|
administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1',
|
||||||
status VARCHAR(20) NOT NULL CHECK (status IN ('administered', 'missed')),
|
status VARCHAR(20) NOT NULL CHECK (status IN ('administered', 'missed')),
|
||||||
notes VARCHAR(500),
|
notes VARCHAR(500),
|
||||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
UNIQUE (medication_id, administered_on)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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
|
CREATE INDEX IF NOT EXISTS idx_weight_records_bird_recorded_on
|
||||||
ON weight_records (bird_id, recorded_on DESC);
|
ON weight_records (bird_id, recorded_on DESC);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
BirdRow,
|
BirdRow,
|
||||||
LostBirdMatchRow,
|
LostBirdMatchRow,
|
||||||
MedicationAdministrationRow,
|
MedicationAdministrationRow,
|
||||||
|
MedicationDoseScheduleItem,
|
||||||
MedicationRow,
|
MedicationRow,
|
||||||
PendingBirdTransferRow,
|
PendingBirdTransferRow,
|
||||||
VetVisitRow,
|
VetVisitRow,
|
||||||
@@ -415,7 +416,7 @@ export const deleteVetVisitForBird = async (visitId: string, birdId: string) =>
|
|||||||
|
|
||||||
export const listMedicationsForBird = async (birdId: string, workspaceId: number) => {
|
export const listMedicationsForBird = async (birdId: string, workspaceId: number) => {
|
||||||
const result = await db.query<MedicationRow>(
|
const result = await db.query<MedicationRow>(
|
||||||
`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
|
FROM medications
|
||||||
WHERE bird_id = $1
|
WHERE bird_id = $1
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
@@ -436,16 +437,17 @@ export const createMedicationForBird = async (
|
|||||||
name: string,
|
name: string,
|
||||||
dosage: string,
|
dosage: string,
|
||||||
frequency: string,
|
frequency: string,
|
||||||
|
doseSchedule: MedicationDoseScheduleItem[],
|
||||||
route: string | null,
|
route: string | null,
|
||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string | null,
|
endDate: string | null,
|
||||||
notes: string | null,
|
notes: string | null,
|
||||||
) => {
|
) => {
|
||||||
const result = await db.query<MedicationRow>(
|
const result = await db.query<MedicationRow>(
|
||||||
`INSERT INTO medications (bird_id, name, dosage, frequency, route, start_date, end_date, 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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
RETURNING id, bird_id, name, dosage, frequency, route, start_date::text, end_date::text, notes`,
|
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`,
|
||||||
[birdId, name, dosage, frequency, route, startDate, endDate, notes],
|
[birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes],
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
@@ -457,6 +459,7 @@ export const updateMedicationForBird = async (
|
|||||||
name: string,
|
name: string,
|
||||||
dosage: string,
|
dosage: string,
|
||||||
frequency: string,
|
frequency: string,
|
||||||
|
doseSchedule: MedicationDoseScheduleItem[],
|
||||||
route: string | null,
|
route: string | null,
|
||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string | null,
|
endDate: string | null,
|
||||||
@@ -467,14 +470,15 @@ export const updateMedicationForBird = async (
|
|||||||
SET name = $3,
|
SET name = $3,
|
||||||
dosage = $4,
|
dosage = $4,
|
||||||
frequency = $5,
|
frequency = $5,
|
||||||
route = $6,
|
dose_schedule = $6,
|
||||||
start_date = $7,
|
route = $7,
|
||||||
end_date = $8,
|
start_date = $8,
|
||||||
notes = $9
|
end_date = $9,
|
||||||
|
notes = $10
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND bird_id = $2
|
AND bird_id = $2
|
||||||
RETURNING id, bird_id, name, dosage, frequency, route, start_date::text, end_date::text, notes`,
|
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`,
|
||||||
[medicationId, birdId, name, dosage, frequency, route, startDate, endDate, notes],
|
[medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes],
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
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) => {
|
export const listMedicationAdministrationsForBird = async (birdId: string, workspaceId: number) => {
|
||||||
const result = await db.query<MedicationAdministrationRow>(
|
const result = await db.query<MedicationAdministrationRow>(
|
||||||
`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
|
FROM medication_administrations
|
||||||
WHERE bird_id = $1
|
WHERE bird_id = $1
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
@@ -515,13 +519,14 @@ export const upsertMedicationAdministrationForBird = async (
|
|||||||
birdId: string,
|
birdId: string,
|
||||||
workspaceId: number,
|
workspaceId: number,
|
||||||
administeredOn: string,
|
administeredOn: string,
|
||||||
|
administrationSlot: string,
|
||||||
status: 'administered' | 'missed',
|
status: 'administered' | 'missed',
|
||||||
notes: string | null,
|
notes: string | null,
|
||||||
createdByUserId: string | null,
|
createdByUserId: string | null,
|
||||||
) => {
|
) => {
|
||||||
const result = await db.query<MedicationAdministrationRow>(
|
const result = await db.query<MedicationAdministrationRow>(
|
||||||
`INSERT INTO medication_administrations (medication_id, bird_id, administered_on, status, notes, created_by_user_id)
|
`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
|
SELECT $1, $2, $4, $5, $6, $7, $8
|
||||||
WHERE EXISTS (
|
WHERE EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM medications
|
FROM medications
|
||||||
@@ -530,13 +535,13 @@ export const upsertMedicationAdministrationForBird = async (
|
|||||||
AND medications.bird_id = $2
|
AND medications.bird_id = $2
|
||||||
AND birds.workspace_id = $3
|
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,
|
DO UPDATE SET status = EXCLUDED.status,
|
||||||
notes = EXCLUDED.notes,
|
notes = EXCLUDED.notes,
|
||||||
created_by_user_id = EXCLUDED.created_by_user_id,
|
created_by_user_id = EXCLUDED.created_by_user_id,
|
||||||
created_at = CURRENT_TIMESTAMP
|
created_at = CURRENT_TIMESTAMP
|
||||||
RETURNING id, medication_id, bird_id, administered_on::text, status, notes, created_by_user_id, created_at`,
|
RETURNING id, medication_id, bird_id, administered_on::text, administration_slot, status, notes, created_by_user_id, created_at`,
|
||||||
[medicationId, birdId, workspaceId, administeredOn, status, notes, createdByUserId],
|
[medicationId, birdId, workspaceId, administeredOn, administrationSlot, status, notes, createdByUserId],
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
|
|||||||
@@ -150,17 +150,25 @@ export type MedicationRow = {
|
|||||||
name: string;
|
name: string;
|
||||||
dosage: string;
|
dosage: string;
|
||||||
frequency: string;
|
frequency: string;
|
||||||
|
dose_schedule: MedicationDoseScheduleItem[];
|
||||||
route: string | null;
|
route: string | null;
|
||||||
start_date: string;
|
start_date: string;
|
||||||
end_date: string | null;
|
end_date: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MedicationDoseScheduleItem = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
time: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type MedicationAdministrationRow = {
|
export type MedicationAdministrationRow = {
|
||||||
id: string;
|
id: string;
|
||||||
medication_id: string;
|
medication_id: string;
|
||||||
bird_id: string;
|
bird_id: string;
|
||||||
administered_on: string;
|
administered_on: string;
|
||||||
|
administration_slot: string;
|
||||||
status: 'administered' | 'missed';
|
status: 'administered' | 'missed';
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
created_by_user_id: string | null;
|
created_by_user_id: string | null;
|
||||||
|
|||||||
+73
-2
@@ -254,7 +254,19 @@ Role requirements are called out per endpoint below. If the signed-in member lac
|
|||||||
"birdId": "uuid",
|
"birdId": "uuid",
|
||||||
"name": "Meloxicam",
|
"name": "Meloxicam",
|
||||||
"dosage": "0.05 mL",
|
"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",
|
"route": "Oral",
|
||||||
"startDate": "2026-04-14",
|
"startDate": "2026-04-14",
|
||||||
"endDate": null,
|
"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
|
## Common Validation Rules
|
||||||
|
|
||||||
- Dates use `YYYY-MM-DD`
|
- Dates use `YYYY-MM-DD`
|
||||||
@@ -926,7 +954,19 @@ Request body:
|
|||||||
{
|
{
|
||||||
"name": "Meloxicam",
|
"name": "Meloxicam",
|
||||||
"dosage": "0.05 mL",
|
"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",
|
"route": "Oral",
|
||||||
"startDate": "2026-04-14",
|
"startDate": "2026-04-14",
|
||||||
"endDate": "",
|
"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.
|
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:
|
Possible errors:
|
||||||
|
|
||||||
- `400` if `endDate` is before `startDate`
|
- `400` if `endDate` is before `startDate`
|
||||||
|
|||||||
+195
-44
@@ -52,18 +52,28 @@ type Medication = {
|
|||||||
birdId: string;
|
birdId: string;
|
||||||
name: string;
|
name: string;
|
||||||
dosage: string;
|
dosage: string;
|
||||||
frequency: string;
|
frequency: MedicationFrequency;
|
||||||
|
doseSchedule: MedicationDoseScheduleItem[];
|
||||||
route: string | null;
|
route: string | null;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string | null;
|
endDate: string | null;
|
||||||
notes: 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 = {
|
type MedicationAdministration = {
|
||||||
id: string;
|
id: string;
|
||||||
medicationId: string;
|
medicationId: string;
|
||||||
birdId: string;
|
birdId: string;
|
||||||
administeredOn: string;
|
administeredOn: string;
|
||||||
|
administrationSlot: string;
|
||||||
status: 'administered' | 'missed';
|
status: 'administered' | 'missed';
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -471,6 +481,37 @@ const PHOTO_PREVIEW_SIZE = 112;
|
|||||||
const MEMBER_CHART_WIDTH = 520;
|
const MEMBER_CHART_WIDTH = 520;
|
||||||
const MEMBER_CHART_HEIGHT = 180;
|
const MEMBER_CHART_HEIGHT = 180;
|
||||||
const MEMBER_CHART_PADDING = { top: 16, right: 18, bottom: 34, left: 52 };
|
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 readJsonSafely = async <T,>(response: Response): Promise<T | null> => {
|
||||||
const contentType = response.headers.get('content-type') ?? '';
|
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 assessBirdWeight = (bird: Bird): BirdWeightAssessment => {
|
||||||
const reference = findParrotWeightReference(bird.species);
|
const reference = findParrotWeightReference(bird.species);
|
||||||
|
|
||||||
@@ -1038,7 +1119,8 @@ function App() {
|
|||||||
const [medicationForm, setMedicationForm] = useState({
|
const [medicationForm, setMedicationForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
dosage: '',
|
dosage: '',
|
||||||
frequency: '',
|
frequency: 'once_daily' as MedicationFrequency,
|
||||||
|
doseSchedule: getDefaultMedicationDoseSchedule('once_daily'),
|
||||||
route: '',
|
route: '',
|
||||||
startDate: new Date().toISOString().slice(0, 10),
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
endDate: '',
|
endDate: '',
|
||||||
@@ -2581,7 +2663,8 @@ function App() {
|
|||||||
setMedicationForm({
|
setMedicationForm({
|
||||||
name: '',
|
name: '',
|
||||||
dosage: '',
|
dosage: '',
|
||||||
frequency: '',
|
frequency: 'once_daily',
|
||||||
|
doseSchedule: getDefaultMedicationDoseSchedule('once_daily'),
|
||||||
route: '',
|
route: '',
|
||||||
startDate: new Date().toISOString().slice(0, 10),
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
endDate: '',
|
endDate: '',
|
||||||
@@ -2594,11 +2677,13 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEditMedication = (medication: Medication) => {
|
const handleEditMedication = (medication: Medication) => {
|
||||||
|
const frequency = normalizeMedicationFrequency(medication.frequency);
|
||||||
setEditingMedicationId(medication.id);
|
setEditingMedicationId(medication.id);
|
||||||
setMedicationForm({
|
setMedicationForm({
|
||||||
name: medication.name,
|
name: medication.name,
|
||||||
dosage: medication.dosage,
|
dosage: medication.dosage,
|
||||||
frequency: medication.frequency,
|
frequency,
|
||||||
|
doseSchedule: medication.doseSchedule?.length ? medication.doseSchedule : getDefaultMedicationDoseSchedule(frequency),
|
||||||
route: medication.route ?? '',
|
route: medication.route ?? '',
|
||||||
startDate: medication.startDate,
|
startDate: medication.startDate,
|
||||||
endDate: medication.endDate ?? '',
|
endDate: medication.endDate ?? '',
|
||||||
@@ -2612,7 +2697,8 @@ function App() {
|
|||||||
setMedicationForm({
|
setMedicationForm({
|
||||||
name: '',
|
name: '',
|
||||||
dosage: '',
|
dosage: '',
|
||||||
frequency: '',
|
frequency: 'once_daily',
|
||||||
|
doseSchedule: getDefaultMedicationDoseSchedule('once_daily'),
|
||||||
route: '',
|
route: '',
|
||||||
startDate: new Date().toISOString().slice(0, 10),
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
endDate: '',
|
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) {
|
if (!selectedBird || savingMedicationAdministrationId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSavingMedicationAdministrationId(`${medicationId}-${status}`);
|
setSavingMedicationAdministrationId(`${medicationId}-${administrationSlot}-${status}`);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2662,7 +2752,7 @@ function App() {
|
|||||||
const response = await apiFetch(`/birds/${selectedBird.id}/medications/${medicationId}/administrations`, authToken, {
|
const response = await apiFetch(`/birds/${selectedBird.id}/medications/${medicationId}/administrations`, authToken, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ administeredOn, status }),
|
body: JSON.stringify({ administeredOn, administrationSlot, status }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -2680,7 +2770,9 @@ function App() {
|
|||||||
(administration, index, all) =>
|
(administration, index, all) =>
|
||||||
all.findIndex(
|
all.findIndex(
|
||||||
(candidate) =>
|
(candidate) =>
|
||||||
candidate.medicationId === administration.medicationId && candidate.administeredOn === administration.administeredOn,
|
candidate.medicationId === administration.medicationId &&
|
||||||
|
candidate.administeredOn === administration.administeredOn &&
|
||||||
|
candidate.administrationSlot === administration.administrationSlot,
|
||||||
) === index,
|
) === index,
|
||||||
)
|
)
|
||||||
.sort((left, right) => right.administeredOn.localeCompare(left.administeredOn) || right.createdAt.localeCompare(left.createdAt)),
|
.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 showWorkspaceSwitcher = authSession.workspaces.length > 1;
|
||||||
const todayDate = new Date().toISOString().slice(0, 10);
|
const todayDate = new Date().toISOString().slice(0, 10);
|
||||||
const renderMedicationCard = (medication: Medication, options: { showActions?: boolean; showAdministrationControls?: boolean }) => {
|
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 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 (
|
return (
|
||||||
<article key={medication.id} className="vet-visit-card">
|
<article key={medication.id} className="vet-visit-card">
|
||||||
<strong>{medication.name}</strong>
|
<strong>{medication.name}</strong>
|
||||||
<span>
|
<span>
|
||||||
{medication.dosage} • {medication.frequency}
|
{medication.dosage} • {formatMedicationFrequency(medication.frequency)}
|
||||||
{medication.route ? ` • ${medication.route}` : ''}
|
{medication.route ? ` • ${medication.route}` : ''}
|
||||||
</span>
|
</span>
|
||||||
<small>
|
<small>
|
||||||
@@ -3314,35 +3402,51 @@ function App() {
|
|||||||
<small>{medication.notes || 'No notes recorded.'}</small>
|
<small>{medication.notes || 'No notes recorded.'}</small>
|
||||||
{latestAdministration ? (
|
{latestAdministration ? (
|
||||||
<small>
|
<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>
|
</small>
|
||||||
) : null}
|
) : null}
|
||||||
{options.showAdministrationControls ? (
|
{options.showAdministrationControls ? (
|
||||||
<div className="medication-admin-actions">
|
<div className="medication-admin-actions">
|
||||||
<small>
|
<small>Today's interval events</small>
|
||||||
Today:{' '}
|
{doseSlots.map((slot) => {
|
||||||
{todayAdministration
|
const todayAdministration = medicationAdministrations.find(
|
||||||
? `${todayAdministration.status === 'administered' ? 'Given' : 'Missed'} on ${formatShortDate(todayAdministration.administeredOn)}`
|
(administration) =>
|
||||||
: 'Not updated yet'}
|
administration.medicationId === medication.id &&
|
||||||
</small>
|
administration.administeredOn === todayDate &&
|
||||||
<div className="button-row">
|
administration.administrationSlot === slot.key,
|
||||||
<button
|
);
|
||||||
className="primary-button"
|
const givenActionId = `${medication.id}-${slot.key}-administered`;
|
||||||
onClick={() => handleMedicationAdministrationSubmit(medication.id, 'administered')}
|
const missedActionId = `${medication.id}-${slot.key}-missed`;
|
||||||
type="button"
|
|
||||||
disabled={Boolean(savingMedicationAdministrationId)}
|
return (
|
||||||
>
|
<div className="medication-dose-row" key={slot.key}>
|
||||||
{savingMedicationAdministrationId === givenActionId ? 'Saving...' : 'Given today'}
|
<span>
|
||||||
</button>
|
<strong>{slot.label}</strong>
|
||||||
<button
|
{slot.time ? <small>{formatDoseTime(slot.time)}</small> : null}
|
||||||
className="secondary-button"
|
<small>{todayAdministration ? (todayAdministration.status === 'administered' ? 'Administered' : 'Not administered') : 'Unmarked'}</small>
|
||||||
onClick={() => handleMedicationAdministrationSubmit(medication.id, 'missed')}
|
</span>
|
||||||
type="button"
|
<div className="button-row">
|
||||||
disabled={Boolean(savingMedicationAdministrationId)}
|
<button
|
||||||
>
|
className="primary-button"
|
||||||
{savingMedicationAdministrationId === missedActionId ? 'Saving...' : 'Missed today'}
|
onClick={() => handleMedicationAdministrationSubmit(medication.id, slot.key, 'administered')}
|
||||||
</button>
|
type="button"
|
||||||
</div>
|
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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{options.showActions ? (
|
{options.showActions ? (
|
||||||
@@ -5068,12 +5172,24 @@ function App() {
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Frequency
|
Frequency
|
||||||
<input
|
<select
|
||||||
value={medicationForm.frequency}
|
value={medicationForm.frequency}
|
||||||
onChange={(event) => setMedicationForm({ ...medicationForm, frequency: event.target.value })}
|
onChange={(event) => {
|
||||||
placeholder="Every 12 hours"
|
const frequency = event.target.value as MedicationFrequency;
|
||||||
|
setMedicationForm({
|
||||||
|
...medicationForm,
|
||||||
|
frequency,
|
||||||
|
doseSchedule: getDefaultMedicationDoseSchedule(frequency),
|
||||||
|
});
|
||||||
|
}}
|
||||||
required
|
required
|
||||||
/>
|
>
|
||||||
|
{medicationFrequencyOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Route
|
Route
|
||||||
@@ -5100,6 +5216,41 @@ function App() {
|
|||||||
onChange={(event) => setMedicationForm({ ...medicationForm, endDate: event.target.value })}
|
onChange={(event) => setMedicationForm({ ...medicationForm, endDate: event.target.value })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<label className="wide-field">
|
||||||
Notes
|
Notes
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -1196,6 +1196,39 @@ textarea {
|
|||||||
padding-top: 0.35rem;
|
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 {
|
.form-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user