908e4e724f
The shareStockStatus UI toggle was replaced by shareMedicationOverview in
commit e0fb77d, but the backend gating logic was left intact. Users who
had previously set shareStockStatus=false were stuck with empty stock
values ('-') on the shared medication overview with no UI to change it.
- Remove showStockStatus parameter from buildSharedMedicationOverview()
- Remove visibility gating that nullified stock fields
- Remove shareStockStatus from settings API responses and PUT schema
- Remove shareStockStatus from frontend types, hooks, and context
- Clean up all related test fixtures and dead test cases
- DB column share_stock_status retained (never remove columns)
214 lines
6.8 KiB
TypeScript
214 lines
6.8 KiB
TypeScript
import type { doseTracking, medications } from "../db/schema.js";
|
|
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
|
|
import {
|
|
getTodayInTimezone,
|
|
type Intake,
|
|
normalizeIntakeUsageForStock,
|
|
parseIntakesJson,
|
|
parseLocalDateTime,
|
|
} from "../utils/scheduler-utils.js";
|
|
|
|
const MS_PER_DAY = 86_400_000;
|
|
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;
|
|
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) => {
|
|
if (intake.every <= 0) return sum;
|
|
const normalizedUsage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
|
|
return sum + normalizedUsage / intake.every;
|
|
}, 0);
|
|
}
|
|
|
|
function computeNextIntakeDate(intakes: Intake[], todayDateOnly: string): string | null {
|
|
const today = parseDateOnly(todayDateOnly);
|
|
let nextDate: Date | null = null;
|
|
|
|
for (const intake of intakes) {
|
|
if (intake.every <= 0) continue;
|
|
|
|
const startDate = parseLocalDateTime(intake.start);
|
|
const startDateOnly = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate(), 0, 0, 0, 0);
|
|
|
|
let candidate = startDateOnly;
|
|
if (candidate.getTime() < today.getTime()) {
|
|
const elapsedDays = Math.floor((today.getTime() - candidate.getTime()) / MS_PER_DAY);
|
|
const intervals = Math.ceil(elapsedDays / intake.every);
|
|
candidate = new Date(candidate.getTime() + intervals * intake.every * MS_PER_DAY);
|
|
}
|
|
|
|
if (!nextDate || candidate.getTime() < nextDate.getTime()) {
|
|
nextDate = candidate;
|
|
}
|
|
}
|
|
|
|
return nextDate ? toDateOnlyString(nextDate) : null;
|
|
}
|
|
|
|
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 * MS_PER_DAY));
|
|
const priority = computeOverviewPriority(currentStock, daysLeft, thresholdDays);
|
|
return {
|
|
name: medication.name,
|
|
genericName: medication.genericName,
|
|
imageUrl: medication.imageUrl,
|
|
packageType: medication.packageType,
|
|
packCount: medication.packCount,
|
|
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,
|
|
};
|
|
});
|
|
}
|