feat: Add package type support and per-intake takenBy (#89)

## Package Type Feature
- Add 'blister' and 'bottle' package types for medications
- Bottle type uses totalPills for capacity and looseTablets for current stock
- Blister type continues to use packCount/blistersPerPack/pillsPerBlister
- Add doseUnit field for flexible dosing (mg, ml, IU, etc.)
- Full UI support in medication form and detail modal

## Per-Intake TakenBy
- Move takenBy from medication level to individual intakes
- Each intake schedule can now be assigned to a different person
- Update scheduler-utils to handle per-intake takenBy
- Update SharedSchedule to filter by per-intake takenBy
- Backward compatible with existing medication data

## UI Improvements
- Add PasswordInput component with show/hide toggle
- Centralize stockThresholds in AppContext for consistent status display
- Fix SharedSchedule sync issues with per-intake takenBy
- Improve mobile editing experience

## Technical
- Add migrations 0004 and 0005 for schema changes
- Update all relevant tests (1064 tests passing)
- Maintain backward compatibility with ALTER migrations
This commit is contained in:
Daniel Volz
2026-01-31 23:49:11 +01:00
committed by GitHub
parent ac4b8151e4
commit 571d94bf7e
37 changed files with 2896 additions and 990 deletions
+71 -12
View File
@@ -2,29 +2,57 @@
// Core Types for MedAssist
// =============================================================================
export type PackageType = "blister" | "bottle";
// Common medication dose units
export type DoseUnit = "mg" | "g" | "mcg" | "ml";
export const DOSE_UNITS: { value: DoseUnit; label: string }[] = [
{ value: "mg", label: "mg" },
{ value: "g", label: "g" },
{ value: "mcg", label: "mcg (µg)" },
{ value: "ml", label: "ml" },
];
export type Blister = {
usage: number;
every: number;
start: string;
};
/**
* Intake with per-intake takenBy support.
* Extends Blister with per-intake user assignment.
*/
export type Intake = {
usage: number;
every: number;
start: string;
takenBy: string | null; // Per-intake user assignment (single person or null)
intakeRemindersEnabled: boolean;
};
export type Medication = {
id: number;
name: string;
genericName?: string | null;
takenBy: string[];
takenBy: string[]; // Medication-level takenBy (legacy, still used for filtering)
packageType: PackageType;
packCount: number;
blistersPerPack: number;
pillsPerBlister: number;
looseTablets: number;
totalPills?: number | null; // For bottle type: total capacity of the container
looseTablets: number; // For blister: extra loose pills; for bottle: current stock
stockAdjustment?: number;
lastStockCorrectionAt?: string | null;
pillWeightMg?: number | null;
blisters: Blister[];
doseUnit?: DoseUnit | null; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
blisters: Blister[]; // Legacy array format
intakes?: Intake[]; // New intake format with per-intake takenBy
imageUrl?: string | null;
expiryDate?: string | null;
notes?: string | null;
intakeRemindersEnabled?: boolean;
intakeRemindersEnabled?: boolean; // Medication-level setting (deprecated, use per-intake)
dismissedUntil?: string | null; // ISO date string (YYYY-MM-DD) - all past doses until this date are dismissed
updatedAt: string | number | null;
};
@@ -55,19 +83,35 @@ export type FormBlister = {
startTime: string;
};
/**
* Form state for intake entry with per-intake takenBy support.
*/
export type FormIntake = {
usage: string;
every: string;
startDate: string;
startTime: string;
takenBy: string; // Single person or empty string (empty = null for everyone)
intakeRemindersEnabled: boolean;
};
export type FormState = {
name: string;
genericName: string;
takenBy: string[];
takenBy: string[]; // Medication-level takenBy (legacy/compatibility)
packageType: PackageType;
packCount: string;
blistersPerPack: string;
pillsPerBlister: string;
looseTablets: string;
totalPills: string; // For bottle type: total capacity
looseTablets: string; // For blister: extra loose pills; for bottle: current stock
pillWeightMg: string;
doseUnit: DoseUnit; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
expiryDate: string;
notes: string;
intakeRemindersEnabled: boolean;
blisters: FormBlister[];
intakeRemindersEnabled: boolean; // Deprecated, kept for backward compat
blisters: FormBlister[]; // Legacy form format
intakes: FormIntake[]; // New form format with per-intake takenBy
};
export type FieldErrors = {
@@ -87,7 +131,7 @@ export type Coverage = {
};
export type StockStatus = {
level: "out-of-stock" | "low" | "normal" | "high";
level: "out-of-stock" | "critical" | "low" | "normal" | "high";
className: string;
label: string;
};
@@ -96,6 +140,8 @@ export type StockThresholds = {
lowStockDays: number;
normalStockDays: number;
highStockDays: number;
criticalStockDays: number; // Threshold for critical/danger status (typically reminderDaysBefore)
expiryWarningDays: number; // Days before expiry to show warning
};
export type ScheduleEvent = {
@@ -106,7 +152,7 @@ export type ScheduleEvent = {
usage: number;
when: number;
isPast: boolean;
takenBy: string[];
takenBy: string | null; // Per-intake takenBy (single person or null)
};
export type BlisterStock = {
@@ -121,14 +167,16 @@ export type SharedMedication = {
name: string;
genericName?: string | null;
pillWeightMg?: number | null;
doseUnit?: DoseUnit | null;
imageUrl?: string | null;
totalPills: number;
packCount: number;
blistersPerPack: number;
looseTablets: number;
pillsPerBlister: number;
takenBy: string[];
blisters: Blister[];
takenBy: string[]; // Medication-level takenBy (legacy)
blisters: Blister[]; // Legacy array format
intakes?: Intake[]; // New intake format with per-intake takenBy
dismissedUntil?: string | null;
updatedAt?: string | number | null; // For filtering out doses from previous schedule configurations
};
@@ -165,14 +213,25 @@ export const FIELD_LIMITS = {
type MedLike = Pick<Medication, "packCount" | "blistersPerPack" | "pillsPerBlister" | "looseTablets"> & {
stockAdjustment?: number;
packageType?: PackageType;
};
/** Calculate total pills including stockAdjustment */
export function getMedTotal(med: MedLike): number {
// For bottle type, looseTablets IS the current stock
if (med.packageType === "bottle") {
return med.looseTablets + (med.stockAdjustment ?? 0);
}
// For blister type, calculate from packs + loose
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
}
/** Get the base package size (without stockAdjustment) */
export function getPackageSize(med: MedLike): number {
// For bottle type, looseTablets IS the current stock
if (med.packageType === "bottle") {
return med.looseTablets;
}
// For blister type, calculate from packs + loose
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
}