354 lines
11 KiB
Markdown
354 lines
11 KiB
Markdown
# Phase 3 — Regimens & Pill Organizer
|
|
|
|
**Goal**: Define daily medication schedules (regimens) and batch-dispense from the medicine cabinet into a pill organizer. This is the core convenience feature: instead of tracking individual pill consumption daily, users fill their organizer for N days in a single action.
|
|
|
|
**Depends on**: Phase 0, Phase 1 (medicines), Phase 2 (cabinet)
|
|
|
|
---
|
|
|
|
## Deliverables
|
|
|
|
1. `Regimen` MongoDB schema and CRUD API
|
|
2. `OrganizerFill` schema and fill/undo API
|
|
3. Pill organizer fill flow with shortage detection
|
|
4. Burn rate calculation (days until empty per medicine)
|
|
5. Regimen and pill organizer web UI
|
|
|
|
---
|
|
|
|
## Data Model
|
|
|
|
### Regimen Schema
|
|
|
|
```typescript
|
|
// packages/shared/src/types/regimen.ts
|
|
export interface Regimen {
|
|
id: string;
|
|
householdId: string;
|
|
userId: string; // Regimens are per-person
|
|
name: string; // e.g., "Daily medications", "Morning routine"
|
|
isActive: boolean;
|
|
medications: RegimenMedication[];
|
|
createdBy: string;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}
|
|
|
|
export interface RegimenMedication {
|
|
medicineId: string;
|
|
medicineName: string; // Denormalized
|
|
medicineStrength: number; // Denormalized
|
|
medicineStrengthUnit: StrengthUnit; // Denormalized
|
|
medicineForm: MedicineForm; // Denormalized
|
|
dosage: number; // e.g., 2 (pills per dose)
|
|
dosageUnit: DosageUnit;
|
|
frequency: DosageFrequency;
|
|
customFrequencyPerDay?: number; // When frequency is 'custom'
|
|
timeOfDay?: TimeOfDay;
|
|
instructions?: string; // e.g., "Take with food", "Do not crush"
|
|
}
|
|
|
|
export enum DosageFrequency {
|
|
DAILY = 'daily', // 1x per day
|
|
TWICE_DAILY = 'twice_daily', // 2x per day
|
|
THREE_TIMES_DAILY = 'three_times_daily', // 3x per day
|
|
WEEKLY = 'weekly', // 1x per week
|
|
EVERY_OTHER_DAY = 'every_other_day',
|
|
AS_NEEDED = 'as_needed', // Excluded from organizer fill calculations
|
|
CUSTOM = 'custom', // Uses customFrequencyPerDay
|
|
}
|
|
|
|
export enum TimeOfDay {
|
|
MORNING = 'morning',
|
|
AFTERNOON = 'afternoon',
|
|
EVENING = 'evening',
|
|
BEDTIME = 'bedtime',
|
|
}
|
|
```
|
|
|
|
### OrganizerFill Schema
|
|
|
|
```typescript
|
|
// packages/shared/src/types/organizer-fill.ts
|
|
export interface OrganizerFill {
|
|
id: string;
|
|
householdId: string;
|
|
userId: string;
|
|
regimenId: string;
|
|
regimenName: string; // Denormalized
|
|
numberOfDays: number; // Flexible: 1, 6, 7, 14, etc.
|
|
fillDate: Date;
|
|
items: OrganizerFillItem[];
|
|
status: OrganizerFillStatus;
|
|
notes?: string;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}
|
|
|
|
export interface OrganizerFillItem {
|
|
medicineId: string;
|
|
medicineName: string;
|
|
quantityNeeded: number; // Total pills needed for N days
|
|
quantityTaken: number; // Actual pills taken from cabinet
|
|
wasShort: boolean; // quantityTaken < quantityNeeded
|
|
shortage: number; // quantityNeeded - quantityTaken (0 if not short)
|
|
deductions: OrganizerDeduction[];
|
|
}
|
|
|
|
export interface OrganizerDeduction {
|
|
cabinetItemId: string;
|
|
quantityTaken: number;
|
|
}
|
|
|
|
export enum OrganizerFillStatus {
|
|
COMPLETED = 'completed', // All medicines fully dispensed
|
|
PARTIAL = 'partial', // Some medicines were short
|
|
REVERSED = 'reversed', // Fill was undone
|
|
}
|
|
```
|
|
|
|
### BurnRate (Computed, not stored)
|
|
|
|
```typescript
|
|
// Calculated from active regimens + cabinet stock
|
|
export interface BurnRate {
|
|
medicineId: string;
|
|
medicineName: string;
|
|
dailyConsumption: number; // Total pills per day across all regimens
|
|
totalInCabinet: number;
|
|
daysUntilEmpty: number | null; // null if dailyConsumption is 0
|
|
earliestExpiry: Date | null;
|
|
}
|
|
```
|
|
|
|
### MongoDB Indexes
|
|
|
|
```javascript
|
|
// Regimen
|
|
{ householdId: 1, userId: 1, isActive: 1 }
|
|
{ householdId: 1, 'medications.medicineId': 1 }
|
|
|
|
// OrganizerFill
|
|
{ householdId: 1, userId: 1, fillDate: -1 }
|
|
{ householdId: 1, regimenId: 1, fillDate: -1 }
|
|
{ householdId: 1, status: 1 }
|
|
```
|
|
|
|
---
|
|
|
|
## API Endpoints
|
|
|
|
### RegimensModule
|
|
|
|
| Method | Path | Description | Auth |
|
|
| ------ | ----------------------- | ------------------------------------ | ------ |
|
|
| GET | `/regimens` | List user's regimens | member |
|
|
| GET | `/regimens/:id` | Get single regimen | member |
|
|
| POST | `/regimens` | Create regimen | member |
|
|
| PATCH | `/regimens/:id` | Update regimen | member |
|
|
| DELETE | `/regimens/:id` | Delete regimen | member |
|
|
| GET | `/regimens/burn-rate` | Burn rate for all active regimens | member |
|
|
|
|
### OrganizerModule
|
|
|
|
| Method | Path | Description | Auth |
|
|
| ------ | ----------------------------- | -------------------------------------------- | ------ |
|
|
| GET | `/organizer/fills` | List fill history (paginated) | member |
|
|
| GET | `/organizer/fills/:id` | Get single fill details | member |
|
|
| POST | `/organizer/preview` | Preview a fill (shows quantities, shortages) | member |
|
|
| POST | `/organizer/fill` | Execute a fill (deduct from cabinet) | member |
|
|
| POST | `/organizer/fills/:id/undo` | Reverse a fill (restore cabinet quantities) | member |
|
|
|
|
### Preview Request/Response
|
|
|
|
```typescript
|
|
// POST /organizer/preview
|
|
interface OrganizerPreviewRequest {
|
|
regimenId: string;
|
|
numberOfDays: number; // Default: 7
|
|
}
|
|
|
|
interface OrganizerPreviewResponse {
|
|
regimenName: string;
|
|
numberOfDays: number;
|
|
items: {
|
|
medicineId: string;
|
|
medicineName: string;
|
|
quantityNeeded: number;
|
|
quantityAvailable: number;
|
|
isShort: boolean;
|
|
shortage: number;
|
|
cabinetBreakdown: {
|
|
cabinetItemId: string;
|
|
expirationDate: Date | null;
|
|
quantityToTake: number;
|
|
}[];
|
|
}[];
|
|
canFillCompletely: boolean;
|
|
hasShortages: boolean;
|
|
}
|
|
```
|
|
|
|
### Fill Request
|
|
|
|
```typescript
|
|
// POST /organizer/fill
|
|
interface OrganizerFillRequest {
|
|
regimenId: string;
|
|
numberOfDays: number;
|
|
allowPartial: boolean; // If false, reject when any medicine is short
|
|
notes?: string;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Tasks
|
|
|
|
### 3.1 — Shared Types & Validation
|
|
|
|
- Add regimen types to `packages/shared/src/types/regimen.ts`
|
|
- Add organizer fill types to `packages/shared/src/types/organizer-fill.ts`
|
|
- Zod schemas:
|
|
- `CreateRegimenSchema`
|
|
- `UpdateRegimenSchema`
|
|
- `OrganizerPreviewSchema`
|
|
- `OrganizerFillSchema`
|
|
|
|
### 3.2 — Regimen CRUD
|
|
|
|
- `RegimensRepository` and `RegimensService`
|
|
- Standard CRUD scoped to `householdId` + `userId`
|
|
- On create/update: validate that all `medicineId` references exist in the medicine library
|
|
- Denormalize medicine fields (name, strength, unit, form)
|
|
|
|
### 3.3 — Frequency Multiplier Logic
|
|
|
|
```typescript
|
|
/**
|
|
* Calculate total pills needed for N days based on frequency.
|
|
*
|
|
* daily: dosage * numberOfDays
|
|
* twice_daily: dosage * 2 * numberOfDays
|
|
* three_times_daily: dosage * 3 * numberOfDays
|
|
* weekly: dosage * ceil(numberOfDays / 7)
|
|
* every_other_day: dosage * ceil(numberOfDays / 2)
|
|
* as_needed: 0 (excluded from organizer fills)
|
|
* custom: dosage * customFrequencyPerDay * numberOfDays
|
|
*/
|
|
function calculateQuantityNeeded(
|
|
medication: RegimenMedication,
|
|
numberOfDays: number,
|
|
): number;
|
|
```
|
|
|
|
### 3.4 — Organizer Fill Service
|
|
|
|
```typescript
|
|
class OrganizerService {
|
|
/**
|
|
* Preview: calculate what would happen without deducting.
|
|
* For each medicine in the regimen:
|
|
* 1. Calculate quantity needed (via frequency multiplier)
|
|
* 2. Check cabinet stock (via CabinetService.getAggregateSummary)
|
|
* 3. Plan FEFO deductions (earliest expiry first)
|
|
* 4. Flag shortages
|
|
*/
|
|
preview(householdId: string, regimenId: string, numberOfDays: number): Promise<OrganizerPreviewResponse>;
|
|
|
|
/**
|
|
* Fill: execute the preview plan.
|
|
* 1. Re-validate stock (may have changed since preview)
|
|
* 2. If allowPartial=false and any shortage, reject
|
|
* 3. Call CabinetService.deductStock for each medicine
|
|
* 4. Create OrganizerFill record
|
|
* 5. Return fill details
|
|
*/
|
|
fill(householdId: string, userId: string, request: OrganizerFillRequest): Promise<OrganizerFill>;
|
|
|
|
/**
|
|
* Undo: reverse a fill.
|
|
* 1. Verify fill is not already reversed
|
|
* 2. For each deduction, call CabinetService.restoreStock
|
|
* 3. Mark fill as reversed
|
|
*/
|
|
undoFill(householdId: string, fillId: string): Promise<OrganizerFill>;
|
|
}
|
|
```
|
|
|
|
### 3.5 — Burn Rate Calculation
|
|
|
|
```typescript
|
|
class BurnRateService {
|
|
/**
|
|
* For each medicine across all active regimens for a user:
|
|
* 1. Sum daily consumption: dosage * daily_frequency_multiplier
|
|
* 2. Get total cabinet stock for that medicine
|
|
* 3. daysUntilEmpty = floor(totalInCabinet / dailyConsumption)
|
|
* 4. Include earliest expiry date from cabinet
|
|
*
|
|
* Note: 'as_needed' frequency is excluded from burn rate.
|
|
* Note: If multiple users in household have regimens, each sees their own burn rate.
|
|
*/
|
|
calculateBurnRates(householdId: string, userId: string): Promise<BurnRate[]>;
|
|
}
|
|
```
|
|
|
|
### 3.6 — Web UI: Regimens
|
|
|
|
- `/regimens` page:
|
|
- List of user's regimens with active/inactive toggle
|
|
- Each regimen shows: name, medication count, active status
|
|
- Expand/click to see all medications with dosage details
|
|
- Add/Edit regimen form:
|
|
- Name, active toggle
|
|
- Medications list:
|
|
- Medicine autocomplete (from library)
|
|
- Dosage (number + unit)
|
|
- Frequency dropdown
|
|
- Time of day (optional)
|
|
- Instructions (optional)
|
|
- Add/remove medications
|
|
|
|
### 3.7 — Web UI: Pill Organizer
|
|
|
|
- `/organizer` page:
|
|
- **Fill organizer** section:
|
|
- Select regimen dropdown
|
|
- Number of days input (default: 7, adjustable)
|
|
- "Preview" button -> shows:
|
|
- Per-medicine breakdown: needed vs available
|
|
- Shortage warnings (highlighted)
|
|
- Which cabinet items will be drawn from (FEFO order)
|
|
- "Fill" button -> executes the fill, shows confirmation
|
|
- Option for partial fill when shortages exist
|
|
- **Burn rate** section:
|
|
- Table: medicine name, daily consumption, total in cabinet, days until empty
|
|
- Color-coded: green (>14 days), yellow (7-14 days), red (<7 days)
|
|
- Links to refill alerts (Phase 4)
|
|
- **Fill history** section:
|
|
- Recent fills with date, regimen, day count, status
|
|
- Expand to see per-medicine details
|
|
- "Undo" button on recent fills (with confirmation)
|
|
|
|
---
|
|
|
|
## Acceptance Criteria
|
|
|
|
- [ ] Can create and manage regimens with multiple medications
|
|
- [ ] Frequency multiplier correctly calculates quantities for all frequency types
|
|
- [ ] Preview accurately shows needed quantities and shortages
|
|
- [ ] Fill deducts from cabinet using FEFO (earliest expiry first)
|
|
- [ ] Partial fills work when `allowPartial` is true
|
|
- [ ] Fill is rejected when `allowPartial` is false and any medicine is short
|
|
- [ ] Undo fully restores cabinet quantities
|
|
- [ ] Undo is idempotent (cannot undo an already-reversed fill)
|
|
- [ ] Burn rate correctly accounts for all active regimens
|
|
- [ ] `as_needed` frequency is excluded from fill calculations and burn rate
|
|
- [ ] All queries scoped to `householdId`; regimens additionally scoped to `userId`
|
|
|
|
---
|
|
|
|
## Estimated Effort
|
|
|
|
Medium-large. The fill/undo transactional logic with FEFO, shortage handling, and burn rate calculations are the most complex parts. UI is moderately complex with the preview/fill flow.
|