68ab79c713
Closes #463
213 lines
6.8 KiB
TypeScript
213 lines
6.8 KiB
TypeScript
import type { doseTracking, medications } from "../db/schema.js";
|
|
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
|
|
import {
|
|
getAverageOccurrencesPerDay,
|
|
getNextScheduledOccurrenceTime,
|
|
getTodayInTimezone,
|
|
type Intake,
|
|
normalizeIntakeUsageForStock,
|
|
parseIntakesJson,
|
|
} from "../utils/scheduler-utils.js";
|
|
|
|
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
|
|
|
type MedicationRow = typeof medications.$inferSelect;
|
|
type DoseRow = typeof doseTracking.$inferSelect;
|
|
|
|
export type SharedMedicationOverviewItem = {
|
|
name: string;
|
|
genericName: string | null;
|
|
imageUrl: string | null;
|
|
packageType: string;
|
|
packCount: number;
|
|
packageAmountValue: number | null;
|
|
packageAmountUnit: "ml" | "g" | null;
|
|
blistersPerPack: number;
|
|
pillsPerBlister: number;
|
|
totalPills: number | null;
|
|
looseTablets: number;
|
|
currentStock: number | null;
|
|
capacity: number | null;
|
|
daysLeft: number | null;
|
|
nextIntakeDate: string | null;
|
|
depletionDate: string | null;
|
|
priority: "normal" | "high" | "out-of-stock" | null;
|
|
expiryDate: string | null;
|
|
medicationStartDate: string | null;
|
|
prescriptionEnabled: boolean;
|
|
prescriptionRemainingRefills: number | null;
|
|
};
|
|
|
|
function toDateOnlyString(date: Date): string {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
const day = String(date.getDate()).padStart(2, "0");
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
function parseDateOnly(dateOnly: string): Date {
|
|
const [year, month, day] = dateOnly.split("-").map((value) => Number.parseInt(value, 10));
|
|
return new Date(year, month - 1, day, 0, 0, 0, 0);
|
|
}
|
|
|
|
function computeCapacity(medication: MedicationRow): number {
|
|
if (isAmountBasedPackageType(medication.packageType)) {
|
|
return medication.totalPills ?? medication.looseTablets;
|
|
}
|
|
|
|
return medication.packCount * medication.blistersPerPack * medication.pillsPerBlister;
|
|
}
|
|
|
|
function computeDailyDoseRate(intakes: Intake[], medication: MedicationRow): number {
|
|
return intakes.reduce((sum, intake) => {
|
|
const normalizedUsage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
|
|
return sum + normalizedUsage * getAverageOccurrencesPerDay(intake);
|
|
}, 0);
|
|
}
|
|
|
|
function computeNextIntakeDate(intakes: Intake[], todayDateOnly: string): string | null {
|
|
const today = parseDateOnly(todayDateOnly);
|
|
let nextOccurrenceMs: number | null = null;
|
|
|
|
for (const intake of intakes) {
|
|
const occurrenceMs = getNextScheduledOccurrenceTime(intake, today.getTime(), true);
|
|
if (occurrenceMs === null) {
|
|
continue;
|
|
}
|
|
|
|
if (nextOccurrenceMs === null || occurrenceMs < nextOccurrenceMs) {
|
|
nextOccurrenceMs = occurrenceMs;
|
|
}
|
|
}
|
|
|
|
return nextOccurrenceMs === null ? null : toDateOnlyString(new Date(nextOccurrenceMs));
|
|
}
|
|
|
|
function computeTakenAmount(
|
|
medication: MedicationRow,
|
|
intakes: Intake[],
|
|
dosesByMedication: Map<number, DoseRow[]>
|
|
): number {
|
|
const doseRows = dosesByMedication.get(medication.id) ?? [];
|
|
if (doseRows.length === 0) return 0;
|
|
|
|
const correctionDateOnlyMs = medication.lastStockCorrectionAt
|
|
? new Date(
|
|
medication.lastStockCorrectionAt.getFullYear(),
|
|
medication.lastStockCorrectionAt.getMonth(),
|
|
medication.lastStockCorrectionAt.getDate(),
|
|
0,
|
|
0,
|
|
0,
|
|
0
|
|
).getTime()
|
|
: 0;
|
|
|
|
let takenAmount = 0;
|
|
for (const dose of doseRows) {
|
|
if (dose.dismissed) continue;
|
|
|
|
const match = doseIdPattern.exec(dose.doseId);
|
|
if (!match) continue;
|
|
|
|
const intakeIndex = Number.parseInt(match[2], 10);
|
|
const doseDateOnlyMs = Number.parseInt(match[3], 10);
|
|
if (Number.isNaN(intakeIndex) || Number.isNaN(doseDateOnlyMs)) continue;
|
|
if (doseDateOnlyMs < correctionDateOnlyMs) continue;
|
|
|
|
const intake = intakes[intakeIndex];
|
|
if (!intake) continue;
|
|
|
|
takenAmount += normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
|
|
}
|
|
|
|
return takenAmount;
|
|
}
|
|
|
|
function toNullableDate(value: string | null): string | null {
|
|
if (!value) return null;
|
|
return value.trim() ? value : null;
|
|
}
|
|
|
|
function computeOverviewPriority(
|
|
currentStock: number,
|
|
daysLeft: number | null,
|
|
thresholdDays: number
|
|
): "normal" | "high" | "out-of-stock" {
|
|
if (currentStock <= 0 || daysLeft === 0) return "out-of-stock";
|
|
if (daysLeft !== null && daysLeft <= thresholdDays) return "high";
|
|
return "normal";
|
|
}
|
|
|
|
export function buildSharedMedicationOverview(options: {
|
|
medications: MedicationRow[];
|
|
doses: DoseRow[];
|
|
thresholdDays: number;
|
|
}): SharedMedicationOverviewItem[] {
|
|
const { medications: medicationRows, doses, thresholdDays } = options;
|
|
|
|
const dosesByMedication = new Map<number, DoseRow[]>();
|
|
for (const dose of doses) {
|
|
const match = doseIdPattern.exec(dose.doseId);
|
|
if (!match) continue;
|
|
|
|
const medicationId = Number.parseInt(match[1], 10);
|
|
if (Number.isNaN(medicationId)) continue;
|
|
|
|
const existing = dosesByMedication.get(medicationId) ?? [];
|
|
existing.push(dose);
|
|
dosesByMedication.set(medicationId, existing);
|
|
}
|
|
|
|
const todayDateOnly = getTodayInTimezone();
|
|
const todayDate = parseDateOnly(todayDateOnly);
|
|
|
|
return medicationRows.map((medication) => {
|
|
const intakes = parseIntakesJson(
|
|
medication.intakesJson,
|
|
{
|
|
usageJson: medication.usageJson,
|
|
everyJson: medication.everyJson,
|
|
startJson: medication.startJson,
|
|
},
|
|
medication.intakeRemindersEnabled ?? false
|
|
);
|
|
|
|
const capacity = computeCapacity(medication);
|
|
const dailyDoseRate = computeDailyDoseRate(intakes, medication);
|
|
const takenAmount = computeTakenAmount(medication, intakes, dosesByMedication);
|
|
const rawCurrentStock = capacity + (medication.stockAdjustment ?? 0) - takenAmount;
|
|
const currentStock = Math.max(0, Math.floor(rawCurrentStock));
|
|
const daysLeft = dailyDoseRate > 0 ? Math.floor(currentStock / dailyDoseRate) : null;
|
|
const depletionDate =
|
|
daysLeft === null ? null : toDateOnlyString(new Date(todayDate.getTime() + daysLeft * 86_400_000));
|
|
const priority = computeOverviewPriority(currentStock, daysLeft, thresholdDays);
|
|
return {
|
|
name: medication.name,
|
|
genericName: medication.genericName,
|
|
imageUrl: medication.imageUrl,
|
|
packageType: medication.packageType,
|
|
packCount: medication.packCount,
|
|
packageAmountValue: medication.packageAmountValue,
|
|
packageAmountUnit:
|
|
medication.packageAmountUnit === "g" || medication.packageAmountUnit === "ml"
|
|
? medication.packageAmountUnit
|
|
: null,
|
|
blistersPerPack: medication.blistersPerPack,
|
|
pillsPerBlister: medication.pillsPerBlister,
|
|
totalPills: medication.totalPills,
|
|
looseTablets: medication.looseTablets,
|
|
currentStock,
|
|
capacity,
|
|
daysLeft,
|
|
nextIntakeDate: computeNextIntakeDate(intakes, todayDateOnly),
|
|
depletionDate,
|
|
priority,
|
|
expiryDate: toNullableDate(medication.expiryDate),
|
|
medicationStartDate: toNullableDate(medication.medicationStartDate),
|
|
prescriptionEnabled: medication.prescriptionEnabled ?? false,
|
|
prescriptionRemainingRefills: medication.prescriptionRemainingRefills,
|
|
};
|
|
});
|
|
}
|